diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index ed92566b..bb00a3cf 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -923,6 +923,283 @@ jobs: body: 'โœ… Sandbox Tests passed!' }); + # ============================================================================= + # 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"] + if: > + ${{ github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' }} + steps: + - name: "Download artifact" + uses: actions/github-script@v7 + with: + script: | + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "pr" + })[0]; + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data)); + + - name: "Read NR & SHA" + run: | + unzip pr.zip + cat NR + cat SHA + 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: '๐Ÿค– Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...' + }); + + - name: Checkout Kun + uses: actions/checkout@v4 + with: + repository: yaoapp/kun + path: kun + + - name: Checkout Xun + uses: actions/checkout@v4 + with: + repository: yaoapp/xun + path: xun + + - name: Checkout Gou + uses: actions/checkout@v4 + with: + repository: yaoapp/gou + path: gou + + - name: Checkout V8Go + uses: actions/checkout@v4 + with: + repository: yaoapp/v8go + path: v8go + + - name: Unzip libv8 + run: | + files=$(find ./v8go -name "libv8*.zip") + for file in $files; do + dir=$(dirname "$file") + echo "Extracting $file to directory $dir" + unzip -o -d $dir $file + rm -rf $dir/__MACOSX + done + + - name: Checkout Demo App + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-dev-app + path: app + + - name: Checkout Extension + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-extensions-dev + path: extension + + - name: Move Dependencies + run: | + mv kun ../ + mv xun ../ + mv gou ../ + mv v8go ../ + mv app ../ + mv extension ../ + + - name: Checkout pull request HEAD commit + uses: actions/checkout@v4 + 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: 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 instance + run: | + docker run -d --name tai-docker \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + echo "Tai Docker HTTP ready"; break + fi + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9100 2>/dev/null; then + echo "Tai Docker gRPC ready"; break + fi + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9100 2>/dev/null || { + echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 + } + + - name: Generate kubeconfig for Tai K8s + run: | + K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') + echo "k3d server IP: ${K3D_IP}" + + k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml + + # Kubeconfig for tai-k8s container (uses k3d-internal IP) + sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \ + > /tmp/kubeconfig-tai-k8s.yml + echo "Container kubeconfig server:" + grep server: /tmp/kubeconfig-tai-k8s.yml + + # Kubeconfig for test runner (uses localhost via port-mapped 6443) + sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ + > ${{ runner.temp }}/kubeconfig-tai.yml + echo "Test runner kubeconfig server:" + grep server: ${{ runner.temp }}/kubeconfig-tai.yml + + - name: Start Tai K8s instance + 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-k8s \ + --network k3d-tai-test \ + -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \ + -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ + -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + echo "Tai K8s HTTP ready"; break + fi + echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9101 2>/dev/null; then + echo "Tai K8s gRPC ready"; break + fi + echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9101 2>/dev/null || { + echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 + } + + - name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace) + env: + TAI_TEST_HOST: "127.0.0.1" + TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" + TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_K8S_HOST: "127.0.0.1" + TAI_TEST_K8S_PORT: "6443" + TAI_TEST_K8S_GRPC_PORT: "9101" + 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 + if: always() + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + - name: "Comment on PR - Sandbox V2 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: 'โœ… Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!' + }); + # ============================================================================= # Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3 # ============================================================================= @@ -1533,20 +1810,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"] @@ -1583,20 +1850,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: @@ -1679,8 +1932,9 @@ 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 @@ -1693,91 +1947,89 @@ jobs: 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) + - name: Start Tai Docker instance (benchmarks) + run: | + docker run -d --name tai-docker \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + echo "Tai Docker HTTP ready"; break + fi + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9100 2>/dev/null; then + echo "Tai Docker gRPC ready"; break + fi + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9100 2>/dev/null || { + echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 + } + + - name: Generate kubeconfig for benchmarks + run: | + K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') + k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d-bench.yml + sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d-bench.yml \ + > /tmp/kubeconfig-tai-k8s-bench.yml + sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d-bench.yml \ + > ${{ runner.temp }}/kubeconfig-tai.yml + + - name: Start Tai K8s instance (benchmarks) 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 \ + docker run -d --name tai-k8s \ --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 \ + -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /tmp/kubeconfig-tai-k8s-bench.yml:/etc/tai/kubeconfig.yml:ro \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ + -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ 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 + if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + echo "Tai K8s HTTP ready"; break fi - echo "Waiting for Tai HTTP... ($i)" - sleep 1 + echo "Waiting for Tai K8s 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 + curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 + } - 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 + if nc -z 127.0.0.1 9101 2>/dev/null; then + echo "Tai K8s gRPC ready"; break fi - echo "Waiting for Tai gRPC... ($i)" - sleep 1 + echo "Waiting for Tai K8s 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 + nc -z 127.0.0.1 9101 2>/dev/null || { + echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 + } - - name: Generate kubeconfig for Tai K8s proxy - run: | - k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml - sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ - > ${{ runner.temp }}/kubeconfig-tai.yml - echo "Generated kubeconfig:" - grep server: ${{ runner.temp }}/kubeconfig-tai.yml - - - name: Run 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_GRPC_PORT: "9100" TAI_TEST_K8S_HOST: "127.0.0.1" TAI_TEST_K8S_PORT: "6443" + TAI_TEST_K8S_GRPC_PORT: "9101" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" 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 07d7111c..99b8829d 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -679,6 +679,221 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} + # ============================================================================= + # 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"] + steps: + - name: Checkout Kun + uses: actions/checkout@v4 + with: + repository: ${{ env.REPO_KUN }} + path: kun + + - name: Checkout Xun + uses: actions/checkout@v4 + with: + repository: ${{ env.REPO_XUN }} + path: xun + + - name: Checkout Gou + uses: actions/checkout@v4 + with: + repository: ${{ env.REPO_GOU }} + path: gou + + - name: Checkout V8Go + uses: actions/checkout@v4 + with: + repository: yaoapp/v8go + path: v8go + + - name: Unzip libv8 + run: | + files=$(find ./v8go -name "libv8*.zip") + for file in $files; do + dir=$(dirname "$file") + echo "Extracting $file to directory $dir" + unzip -o -d $dir $file + rm -rf $dir/__MACOSX + done + + - name: Checkout Demo App + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-dev-app + path: app + + - name: Checkout Extension + uses: actions/checkout@v4 + with: + repository: yaoapp/yao-extensions-dev + path: extension + + - name: Move Dependencies + run: | + mv kun ../ + mv xun ../ + mv gou ../ + mv v8go ../ + mv app ../ + mv extension ../ + + - 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: 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 instance + run: | + docker run -d --name tai-docker \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + echo "Tai Docker HTTP ready"; break + fi + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9100 2>/dev/null; then + echo "Tai Docker gRPC ready"; break + fi + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9100 2>/dev/null || { + echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 + } + + - name: Generate kubeconfig for Tai K8s + run: | + K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') + echo "k3d server IP: ${K3D_IP}" + + k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml + + # Kubeconfig for tai-k8s container (uses k3d-internal IP) + sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \ + > /tmp/kubeconfig-tai-k8s.yml + echo "Container kubeconfig server:" + grep server: /tmp/kubeconfig-tai-k8s.yml + + # Kubeconfig for test runner (uses localhost via port-mapped 6443) + sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ + > ${{ runner.temp }}/kubeconfig-tai.yml + echo "Test runner kubeconfig server:" + grep server: ${{ runner.temp }}/kubeconfig-tai.yml + + - name: Start Tai K8s instance + 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-k8s \ + --network k3d-tai-test \ + -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \ + -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ + -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + echo "Tai K8s HTTP ready"; break + fi + echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9101 2>/dev/null; then + echo "Tai K8s gRPC ready"; break + fi + echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9101 2>/dev/null || { + echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 + } + + - name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace) + env: + TAI_TEST_HOST: "127.0.0.1" + TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" + TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_K8S_HOST: "127.0.0.1" + TAI_TEST_K8S_PORT: "6443" + TAI_TEST_K8S_GRPC_PORT: "9101" + 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 + if: always() + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + # ============================================================================= # Benchmark & Memory Leak Tests - Run with MySQL8.0 and SQLite3 # ============================================================================= @@ -1135,20 +1350,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"] @@ -1233,8 +1438,9 @@ 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 @@ -1247,77 +1453,89 @@ jobs: 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) + - name: Start Tai Docker instance (benchmarks) + run: | + docker run -d --name tai-docker \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + echo "Tai Docker HTTP ready"; break + fi + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9100 2>/dev/null; then + echo "Tai Docker gRPC ready"; break + fi + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9100 2>/dev/null || { + echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 + } + + - name: Generate kubeconfig for benchmarks + run: | + K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') + k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d-bench.yml + sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d-bench.yml \ + > /tmp/kubeconfig-tai-k8s-bench.yml + sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d-bench.yml \ + > ${{ runner.temp }}/kubeconfig-tai.yml + + - name: Start Tai K8s instance (benchmarks) 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 \ + docker run -d --name tai-k8s \ --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 \ + -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /tmp/kubeconfig-tai-k8s-bench.yml:/etc/tai/kubeconfig.yml:ro \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ + -e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \ 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 + if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + echo "Tai K8s HTTP ready"; break fi - echo "Waiting for Tai HTTP... ($i)" - sleep 1 + echo "Waiting for Tai K8s 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 + curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 + } - 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 + if nc -z 127.0.0.1 9101 2>/dev/null; then + echo "Tai K8s gRPC ready"; break fi - echo "Waiting for Tai gRPC... ($i)" - sleep 1 + echo "Waiting for Tai K8s 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 + nc -z 127.0.0.1 9101 2>/dev/null || { + echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 + } - - name: Generate kubeconfig for Tai K8s proxy - run: | - k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml - sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \ - > ${{ runner.temp }}/kubeconfig-tai.yml - echo "Generated kubeconfig:" - grep server: ${{ runner.temp }}/kubeconfig-tai.yml - - - name: Run 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_GRPC_PORT: "9100" TAI_TEST_K8S_HOST: "127.0.0.1" TAI_TEST_K8S_PORT: "6443" + TAI_TEST_K8S_GRPC_PORT: "9101" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" 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/.gitignore b/.gitignore index 5c81f9b5..e1541c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,7 @@ tg-send registry/data/ registry/manager/DESIGN*.md tai/testdata/ +sandbox/v2/docker/base/*-amd64 +sandbox/v2/docker/base/*-arm64 +!sandbox/v2/docker/*.sh +!sandbox/v2/docker/*/*.sh \ No newline at end of file diff --git a/Makefile b/Makefile index 68790267..5c976622 100644 --- a/Makefile +++ b/Makefile @@ -19,10 +19,12 @@ TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/ TESTFOLDER_KB := $(shell $(GO) list ./kb/...) # Robot tests (agent/robot/... packages, excluding events/integrations which require Telegram etc.) TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot/events') -# Sandbox tests (requires Docker) -TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/...) +# Sandbox tests (requires Docker) โ€” excludes sandbox/v2 (has its own job) +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,6 +201,96 @@ unit-test-registry: rm profile.out; \ fi +# --------------------------------------------------------------------------- +# 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-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 "=============================================" + $(MAKE) -C sandbox/v2 test-ci TEST_IMAGE=$(SANDBOX_V2_IMAGE) + +# 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 unit-test-sandbox: @@ -251,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/grpc/auth/endpoint.go b/grpc/auth/endpoint.go index 67fcaf6e..afebcfde 100644 --- a/grpc/auth/endpoint.go +++ b/grpc/auth/endpoint.go @@ -60,6 +60,9 @@ func VirtualEndpoint(fullMethod string, req interface{}) (method string, path st } return "POST", "/grpc/agent/" + case "/yao.Yao/Heartbeat": + return "POST", "/grpc/heartbeat" + default: return "POST", "/grpc/unknown" } diff --git a/grpc/auth/endpoint_test.go b/grpc/auth/endpoint_test.go index edbe3c1e..123c6697 100644 --- a/grpc/auth/endpoint_test.go +++ b/grpc/auth/endpoint_test.go @@ -134,3 +134,15 @@ func TestVirtualEndpoint_AgentStreamEmptyID(t *testing.T) { assert.Equal(t, "POST", method) assert.Equal(t, "/grpc/agent/", path) } + +func TestVirtualEndpoint_Heartbeat(t *testing.T) { + method, path := auth.VirtualEndpoint("/yao.Yao/Heartbeat", &pb.HeartbeatRequest{SandboxId: "sb-1"}) + assert.Equal(t, "POST", method) + assert.Equal(t, "/grpc/heartbeat", path) +} + +func TestVirtualEndpoint_HeartbeatNilReq(t *testing.T) { + method, path := auth.VirtualEndpoint("/yao.Yao/Heartbeat", nil) + assert.Equal(t, "POST", method) + assert.Equal(t, "/grpc/heartbeat", path) +} diff --git a/grpc/auth/scope.go b/grpc/auth/scope.go index 4957c482..71135925 100644 --- a/grpc/auth/scope.go +++ b/grpc/auth/scope.go @@ -7,7 +7,7 @@ func init() { &acl.ScopeDefinition{Name: "grpc:run", Endpoints: []string{"POST /grpc/run/*", "POST /grpc/run/"}}, &acl.ScopeDefinition{Name: "grpc:stream", Endpoints: []string{"POST /grpc/stream/*", "POST /grpc/stream/"}}, &acl.ScopeDefinition{Name: "grpc:shell", Endpoints: []string{"POST /grpc/shell"}}, - &acl.ScopeDefinition{Name: "grpc:mcp", Endpoints: []string{"GET /grpc/mcp/tools", "POST /grpc/mcp/call/*", "POST /grpc/mcp/call/", "GET /grpc/mcp/resources", "GET /grpc/mcp/resources/read"}}, + &acl.ScopeDefinition{Name: "grpc:mcp", Endpoints: []string{"GET /grpc/mcp/tools", "POST /grpc/mcp/call/*", "POST /grpc/mcp/call/", "GET /grpc/mcp/resources", "GET /grpc/mcp/resources/read", "POST /grpc/heartbeat"}}, &acl.ScopeDefinition{Name: "grpc:llm", Endpoints: []string{"POST /grpc/llm/completions"}}, &acl.ScopeDefinition{Name: "grpc:agent", Endpoints: []string{"POST /grpc/agent/*", "POST /grpc/agent/"}}, ) diff --git a/grpc/grpc.go b/grpc/grpc.go index 22755275..1f204034 100644 --- a/grpc/grpc.go +++ b/grpc/grpc.go @@ -21,6 +21,7 @@ import ( mcphandler "github.com/yaoapp/yao/grpc/mcp" "github.com/yaoapp/yao/grpc/pb" runhandler "github.com/yaoapp/yao/grpc/run" + sandboxhandler "github.com/yaoapp/yao/grpc/sandbox" shellhandler "github.com/yaoapp/yao/grpc/shell" ) @@ -33,13 +34,14 @@ var ( type yaoServer struct { pb.UnimplementedYaoServer - health health.Handler - run runhandler.Handler - shell shellhandler.Handler - api apihandler.Handler - mcp mcphandler.Handler - llm llmhandler.Handler - agent agenthandler.Handler + health health.Handler + run runhandler.Handler + shell shellhandler.Handler + api apihandler.Handler + mcp mcphandler.Handler + llm llmhandler.Handler + agent agenthandler.Handler + sandbox *sandboxhandler.Handler } // โ”€โ”€ Health โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -107,6 +109,30 @@ func (s *yaoServer) AgentStream(req *pb.AgentRequest, stream grpc.ServerStreamin return s.agent.AgentStream(req, stream) } +// โ”€โ”€ Sandbox โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func (s *yaoServer) Heartbeat(ctx context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { + if s.sandbox == nil { + return &pb.HeartbeatResponse{Action: "ok"}, nil + } + return s.sandbox.Heartbeat(ctx, req) +} + +// SandboxHandler returns the sandbox handler for external access (e.g., Manager integration). +func SandboxHandler() *sandboxhandler.Handler { + mu.Lock() + defer mu.Unlock() + return sandboxH +} + +var sandboxH *sandboxhandler.Handler + +// SetSandboxOnBeat sets the heartbeat callback for the sandbox handler. +// Must be called before StartServer. +func SetSandboxOnBeat(fn func(data *sandboxhandler.HeartbeatData) string) { + sandboxH = sandboxhandler.NewHandler(fn) +} + // โ”€โ”€ Server lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // StartServer initializes and starts the gRPC server based on config. @@ -124,7 +150,10 @@ func StartServer(cfg config.Config) error { grpc.ChainUnaryInterceptor(auth.UnaryInterceptor), grpc.ChainStreamInterceptor(auth.StreamInterceptor), ) - pb.RegisterYaoServer(server, &yaoServer{}) + if sandboxH == nil { + sandboxH = sandboxhandler.NewHandler(nil) + } + pb.RegisterYaoServer(server, &yaoServer{sandbox: sandboxH}) hosts := strings.Split(cfg.GRPC.Host, ",") port := strconv.Itoa(cfg.GRPC.Port) diff --git a/grpc/pb/yao.pb.go b/grpc/pb/yao.pb.go index cea122a1..fa32b95e 100644 --- a/grpc/pb/yao.pb.go +++ b/grpc/pb/yao.pb.go @@ -2,7 +2,7 @@ // versions: // protoc-gen-go v1.36.11 // protoc v4.25.0 -// source: yao.proto +// source: grpc/pb/yao.proto package pb @@ -32,7 +32,7 @@ type RunRequest struct { func (x *RunRequest) Reset() { *x = RunRequest{} - mi := &file_yao_proto_msgTypes[0] + mi := &file_grpc_pb_yao_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -44,7 +44,7 @@ func (x *RunRequest) String() string { func (*RunRequest) ProtoMessage() {} func (x *RunRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[0] + mi := &file_grpc_pb_yao_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -57,7 +57,7 @@ func (x *RunRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunRequest.ProtoReflect.Descriptor instead. func (*RunRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{0} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{0} } func (x *RunRequest) GetProcess() string { @@ -90,7 +90,7 @@ type RunResponse struct { func (x *RunResponse) Reset() { *x = RunResponse{} - mi := &file_yao_proto_msgTypes[1] + mi := &file_grpc_pb_yao_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -102,7 +102,7 @@ func (x *RunResponse) String() string { func (*RunResponse) ProtoMessage() {} func (x *RunResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[1] + mi := &file_grpc_pb_yao_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -115,7 +115,7 @@ func (x *RunResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunResponse.ProtoReflect.Descriptor instead. func (*RunResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{1} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{1} } func (x *RunResponse) GetData() []byte { @@ -135,7 +135,7 @@ type Chunk struct { func (x *Chunk) Reset() { *x = Chunk{} - mi := &file_yao_proto_msgTypes[2] + mi := &file_grpc_pb_yao_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -147,7 +147,7 @@ func (x *Chunk) String() string { func (*Chunk) ProtoMessage() {} func (x *Chunk) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[2] + mi := &file_grpc_pb_yao_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -160,7 +160,7 @@ func (x *Chunk) ProtoReflect() protoreflect.Message { // Deprecated: Use Chunk.ProtoReflect.Descriptor instead. func (*Chunk) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{2} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{2} } func (x *Chunk) GetData() []byte { @@ -189,7 +189,7 @@ type ShellRequest struct { func (x *ShellRequest) Reset() { *x = ShellRequest{} - mi := &file_yao_proto_msgTypes[3] + mi := &file_grpc_pb_yao_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -201,7 +201,7 @@ func (x *ShellRequest) String() string { func (*ShellRequest) ProtoMessage() {} func (x *ShellRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[3] + mi := &file_grpc_pb_yao_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -214,7 +214,7 @@ func (x *ShellRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ShellRequest.ProtoReflect.Descriptor instead. func (*ShellRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{3} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{3} } func (x *ShellRequest) GetCommand() string { @@ -256,7 +256,7 @@ type ShellResponse struct { func (x *ShellResponse) Reset() { *x = ShellResponse{} - mi := &file_yao_proto_msgTypes[4] + mi := &file_grpc_pb_yao_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -268,7 +268,7 @@ func (x *ShellResponse) String() string { func (*ShellResponse) ProtoMessage() {} func (x *ShellResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[4] + mi := &file_grpc_pb_yao_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -281,7 +281,7 @@ func (x *ShellResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ShellResponse.ProtoReflect.Descriptor instead. func (*ShellResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{4} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{4} } func (x *ShellResponse) GetStdout() []byte { @@ -317,7 +317,7 @@ type APIRequest struct { func (x *APIRequest) Reset() { *x = APIRequest{} - mi := &file_yao_proto_msgTypes[5] + mi := &file_grpc_pb_yao_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -329,7 +329,7 @@ func (x *APIRequest) String() string { func (*APIRequest) ProtoMessage() {} func (x *APIRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[5] + mi := &file_grpc_pb_yao_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -342,7 +342,7 @@ func (x *APIRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use APIRequest.ProtoReflect.Descriptor instead. func (*APIRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{5} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{5} } func (x *APIRequest) GetMethod() string { @@ -384,7 +384,7 @@ type APIResponse struct { func (x *APIResponse) Reset() { *x = APIResponse{} - mi := &file_yao_proto_msgTypes[6] + mi := &file_grpc_pb_yao_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -396,7 +396,7 @@ func (x *APIResponse) String() string { func (*APIResponse) ProtoMessage() {} func (x *APIResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[6] + mi := &file_grpc_pb_yao_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -409,7 +409,7 @@ func (x *APIResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use APIResponse.ProtoReflect.Descriptor instead. func (*APIResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{6} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{6} } func (x *APIResponse) GetStatus() int32 { @@ -442,7 +442,7 @@ type MCPListRequest struct { func (x *MCPListRequest) Reset() { *x = MCPListRequest{} - mi := &file_yao_proto_msgTypes[7] + mi := &file_grpc_pb_yao_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -454,7 +454,7 @@ func (x *MCPListRequest) String() string { func (*MCPListRequest) ProtoMessage() {} func (x *MCPListRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[7] + mi := &file_grpc_pb_yao_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -467,7 +467,7 @@ func (x *MCPListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPListRequest.ProtoReflect.Descriptor instead. func (*MCPListRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{7} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{7} } func (x *MCPListRequest) GetSessionId() string { @@ -486,7 +486,7 @@ type MCPListResponse struct { func (x *MCPListResponse) Reset() { *x = MCPListResponse{} - mi := &file_yao_proto_msgTypes[8] + mi := &file_grpc_pb_yao_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -498,7 +498,7 @@ func (x *MCPListResponse) String() string { func (*MCPListResponse) ProtoMessage() {} func (x *MCPListResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[8] + mi := &file_grpc_pb_yao_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -511,7 +511,7 @@ func (x *MCPListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPListResponse.ProtoReflect.Descriptor instead. func (*MCPListResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{8} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{8} } func (x *MCPListResponse) GetTools() []byte { @@ -532,7 +532,7 @@ type MCPCallRequest struct { func (x *MCPCallRequest) Reset() { *x = MCPCallRequest{} - mi := &file_yao_proto_msgTypes[9] + mi := &file_grpc_pb_yao_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -544,7 +544,7 @@ func (x *MCPCallRequest) String() string { func (*MCPCallRequest) ProtoMessage() {} func (x *MCPCallRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[9] + mi := &file_grpc_pb_yao_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -557,7 +557,7 @@ func (x *MCPCallRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPCallRequest.ProtoReflect.Descriptor instead. func (*MCPCallRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{9} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{9} } func (x *MCPCallRequest) GetSessionId() string { @@ -590,7 +590,7 @@ type MCPCallResponse struct { func (x *MCPCallResponse) Reset() { *x = MCPCallResponse{} - mi := &file_yao_proto_msgTypes[10] + mi := &file_grpc_pb_yao_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -602,7 +602,7 @@ func (x *MCPCallResponse) String() string { func (*MCPCallResponse) ProtoMessage() {} func (x *MCPCallResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[10] + mi := &file_grpc_pb_yao_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -615,7 +615,7 @@ func (x *MCPCallResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPCallResponse.ProtoReflect.Descriptor instead. func (*MCPCallResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{10} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{10} } func (x *MCPCallResponse) GetResult() []byte { @@ -634,7 +634,7 @@ type MCPResourcesResponse struct { func (x *MCPResourcesResponse) Reset() { *x = MCPResourcesResponse{} - mi := &file_yao_proto_msgTypes[11] + mi := &file_grpc_pb_yao_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -646,7 +646,7 @@ func (x *MCPResourcesResponse) String() string { func (*MCPResourcesResponse) ProtoMessage() {} func (x *MCPResourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[11] + mi := &file_grpc_pb_yao_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -659,7 +659,7 @@ func (x *MCPResourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPResourcesResponse.ProtoReflect.Descriptor instead. func (*MCPResourcesResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{11} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{11} } func (x *MCPResourcesResponse) GetResources() []byte { @@ -679,7 +679,7 @@ type MCPResourceRequest struct { func (x *MCPResourceRequest) Reset() { *x = MCPResourceRequest{} - mi := &file_yao_proto_msgTypes[12] + mi := &file_grpc_pb_yao_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -691,7 +691,7 @@ func (x *MCPResourceRequest) String() string { func (*MCPResourceRequest) ProtoMessage() {} func (x *MCPResourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[12] + mi := &file_grpc_pb_yao_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -704,7 +704,7 @@ func (x *MCPResourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPResourceRequest.ProtoReflect.Descriptor instead. func (*MCPResourceRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{12} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{12} } func (x *MCPResourceRequest) GetSessionId() string { @@ -730,7 +730,7 @@ type MCPResourceResponse struct { func (x *MCPResourceResponse) Reset() { *x = MCPResourceResponse{} - mi := &file_yao_proto_msgTypes[13] + mi := &file_grpc_pb_yao_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -742,7 +742,7 @@ func (x *MCPResourceResponse) String() string { func (*MCPResourceResponse) ProtoMessage() {} func (x *MCPResourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[13] + mi := &file_grpc_pb_yao_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -755,7 +755,7 @@ func (x *MCPResourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MCPResourceResponse.ProtoReflect.Descriptor instead. func (*MCPResourceResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{13} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{13} } func (x *MCPResourceResponse) GetContents() []byte { @@ -776,7 +776,7 @@ type ChatRequest struct { func (x *ChatRequest) Reset() { *x = ChatRequest{} - mi := &file_yao_proto_msgTypes[14] + mi := &file_grpc_pb_yao_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -788,7 +788,7 @@ func (x *ChatRequest) String() string { func (*ChatRequest) ProtoMessage() {} func (x *ChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[14] + mi := &file_grpc_pb_yao_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -801,7 +801,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. func (*ChatRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{14} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{14} } func (x *ChatRequest) GetConnector() string { @@ -834,7 +834,7 @@ type ChatResponse struct { func (x *ChatResponse) Reset() { *x = ChatResponse{} - mi := &file_yao_proto_msgTypes[15] + mi := &file_grpc_pb_yao_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -846,7 +846,7 @@ func (x *ChatResponse) String() string { func (*ChatResponse) ProtoMessage() {} func (x *ChatResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[15] + mi := &file_grpc_pb_yao_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -859,7 +859,7 @@ func (x *ChatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead. func (*ChatResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{15} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{15} } func (x *ChatResponse) GetData() []byte { @@ -879,7 +879,7 @@ type ChatChunk struct { func (x *ChatChunk) Reset() { *x = ChatChunk{} - mi := &file_yao_proto_msgTypes[16] + mi := &file_grpc_pb_yao_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -891,7 +891,7 @@ func (x *ChatChunk) String() string { func (*ChatChunk) ProtoMessage() {} func (x *ChatChunk) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[16] + mi := &file_grpc_pb_yao_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -904,7 +904,7 @@ func (x *ChatChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatChunk.ProtoReflect.Descriptor instead. func (*ChatChunk) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{16} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{16} } func (x *ChatChunk) GetData() []byte { @@ -932,7 +932,7 @@ type AgentRequest struct { func (x *AgentRequest) Reset() { *x = AgentRequest{} - mi := &file_yao_proto_msgTypes[17] + mi := &file_grpc_pb_yao_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -944,7 +944,7 @@ func (x *AgentRequest) String() string { func (*AgentRequest) ProtoMessage() {} func (x *AgentRequest) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[17] + mi := &file_grpc_pb_yao_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -957,7 +957,7 @@ func (x *AgentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentRequest.ProtoReflect.Descriptor instead. func (*AgentRequest) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{17} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{17} } func (x *AgentRequest) GetAssistantId() string { @@ -992,7 +992,7 @@ type AgentChunk struct { func (x *AgentChunk) Reset() { *x = AgentChunk{} - mi := &file_yao_proto_msgTypes[18] + mi := &file_grpc_pb_yao_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1004,7 +1004,7 @@ func (x *AgentChunk) String() string { func (*AgentChunk) ProtoMessage() {} func (x *AgentChunk) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[18] + mi := &file_grpc_pb_yao_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1017,7 +1017,7 @@ func (x *AgentChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use AgentChunk.ProtoReflect.Descriptor instead. func (*AgentChunk) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{18} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{18} } func (x *AgentChunk) GetData() []byte { @@ -1042,7 +1042,7 @@ type Empty struct { func (x *Empty) Reset() { *x = Empty{} - mi := &file_yao_proto_msgTypes[19] + mi := &file_grpc_pb_yao_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1054,7 +1054,7 @@ func (x *Empty) String() string { func (*Empty) ProtoMessage() {} func (x *Empty) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[19] + mi := &file_grpc_pb_yao_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1067,7 +1067,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message { // Deprecated: Use Empty.ProtoReflect.Descriptor instead. func (*Empty) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{19} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{19} } type HealthzResponse struct { @@ -1079,7 +1079,7 @@ type HealthzResponse struct { func (x *HealthzResponse) Reset() { *x = HealthzResponse{} - mi := &file_yao_proto_msgTypes[20] + mi := &file_grpc_pb_yao_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1091,7 +1091,7 @@ func (x *HealthzResponse) String() string { func (*HealthzResponse) ProtoMessage() {} func (x *HealthzResponse) ProtoReflect() protoreflect.Message { - mi := &file_yao_proto_msgTypes[20] + mi := &file_grpc_pb_yao_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1104,7 +1104,7 @@ func (x *HealthzResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthzResponse.ProtoReflect.Descriptor instead. func (*HealthzResponse) Descriptor() ([]byte, []int) { - return file_yao_proto_rawDescGZIP(), []int{20} + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{20} } func (x *HealthzResponse) GetStatus() string { @@ -1114,11 +1114,123 @@ func (x *HealthzResponse) GetStatus() string { return "" } -var File_yao_proto protoreflect.FileDescriptor +type HeartbeatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + CpuPercent int32 `protobuf:"varint,2,opt,name=cpu_percent,json=cpuPercent,proto3" json:"cpu_percent,omitempty"` + MemBytes int64 `protobuf:"varint,3,opt,name=mem_bytes,json=memBytes,proto3" json:"mem_bytes,omitempty"` + RunningProcs int32 `protobuf:"varint,4,opt,name=running_procs,json=runningProcs,proto3" json:"running_procs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} -const file_yao_proto_rawDesc = "" + +func (x *HeartbeatRequest) Reset() { + *x = HeartbeatRequest{} + mi := &file_grpc_pb_yao_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatRequest) ProtoMessage() {} + +func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message { + mi := &file_grpc_pb_yao_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatRequest.ProtoReflect.Descriptor instead. +func (*HeartbeatRequest) Descriptor() ([]byte, []int) { + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{21} +} + +func (x *HeartbeatRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *HeartbeatRequest) GetCpuPercent() int32 { + if x != nil { + return x.CpuPercent + } + return 0 +} + +func (x *HeartbeatRequest) GetMemBytes() int64 { + if x != nil { + return x.MemBytes + } + return 0 +} + +func (x *HeartbeatRequest) GetRunningProcs() int32 { + if x != nil { + return x.RunningProcs + } + return 0 +} + +type HeartbeatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` // "ok" or "shutdown" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HeartbeatResponse) Reset() { + *x = HeartbeatResponse{} + mi := &file_grpc_pb_yao_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HeartbeatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HeartbeatResponse) ProtoMessage() {} + +func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message { + mi := &file_grpc_pb_yao_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HeartbeatResponse.ProtoReflect.Descriptor instead. +func (*HeartbeatResponse) Descriptor() ([]byte, []int) { + return file_grpc_pb_yao_proto_rawDescGZIP(), []int{22} +} + +func (x *HeartbeatResponse) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +var File_grpc_pb_yao_proto protoreflect.FileDescriptor + +const file_grpc_pb_yao_proto_rawDesc = "" + "\n" + - "\tyao.proto\x12\x03yao\"T\n" + + "\x11grpc/pb/yao.proto\x12\x03yao\"T\n" + "\n" + "RunRequest\x12\x18\n" + "\aprocess\x18\x01 \x01(\tR\aprocess\x12\x12\n" + @@ -1196,7 +1308,16 @@ const file_yao_proto_rawDesc = "" + "\x04done\x18\x02 \x01(\bR\x04done\"\a\n" + "\x05Empty\")\n" + "\x0fHealthzResponse\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status2\xb8\x05\n" + + "\x06status\x18\x01 \x01(\tR\x06status\"\x94\x01\n" + + "\x10HeartbeatRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vcpu_percent\x18\x02 \x01(\x05R\n" + + "cpuPercent\x12\x1b\n" + + "\tmem_bytes\x18\x03 \x01(\x03R\bmemBytes\x12#\n" + + "\rrunning_procs\x18\x04 \x01(\x05R\frunningProcs\"+\n" + + "\x11HeartbeatResponse\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action2\xf4\x05\n" + "\x03Yao\x12(\n" + "\x03Run\x12\x0f.yao.RunRequest\x1a\x10.yao.RunResponse\x12'\n" + "\x06Stream\x12\x0f.yao.RunRequest\x1a\n" + @@ -1213,22 +1334,23 @@ const file_yao_proto_rawDesc = "" + "\x15ChatCompletionsStream\x12\x10.yao.ChatRequest\x1a\x0e.yao.ChatChunk0\x01\x123\n" + "\vAgentStream\x12\x11.yao.AgentRequest\x1a\x0f.yao.AgentChunk0\x01\x12+\n" + "\aHealthz\x12\n" + - ".yao.Empty\x1a\x14.yao.HealthzResponseB\x1fZ\x1dgithub.com/yaoapp/yao/grpc/pbb\x06proto3" + ".yao.Empty\x1a\x14.yao.HealthzResponse\x12:\n" + + "\tHeartbeat\x12\x15.yao.HeartbeatRequest\x1a\x16.yao.HeartbeatResponseB\x1fZ\x1dgithub.com/yaoapp/yao/grpc/pbb\x06proto3" var ( - file_yao_proto_rawDescOnce sync.Once - file_yao_proto_rawDescData []byte + file_grpc_pb_yao_proto_rawDescOnce sync.Once + file_grpc_pb_yao_proto_rawDescData []byte ) -func file_yao_proto_rawDescGZIP() []byte { - file_yao_proto_rawDescOnce.Do(func() { - file_yao_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_yao_proto_rawDesc), len(file_yao_proto_rawDesc))) +func file_grpc_pb_yao_proto_rawDescGZIP() []byte { + file_grpc_pb_yao_proto_rawDescOnce.Do(func() { + file_grpc_pb_yao_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_grpc_pb_yao_proto_rawDesc), len(file_grpc_pb_yao_proto_rawDesc))) }) - return file_yao_proto_rawDescData + return file_grpc_pb_yao_proto_rawDescData } -var file_yao_proto_msgTypes = make([]protoimpl.MessageInfo, 24) -var file_yao_proto_goTypes = []any{ +var file_grpc_pb_yao_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_grpc_pb_yao_proto_goTypes = []any{ (*RunRequest)(nil), // 0: yao.RunRequest (*RunResponse)(nil), // 1: yao.RunResponse (*Chunk)(nil), // 2: yao.Chunk @@ -1250,14 +1372,16 @@ var file_yao_proto_goTypes = []any{ (*AgentChunk)(nil), // 18: yao.AgentChunk (*Empty)(nil), // 19: yao.Empty (*HealthzResponse)(nil), // 20: yao.HealthzResponse - nil, // 21: yao.ShellRequest.EnvEntry - nil, // 22: yao.APIRequest.HeadersEntry - nil, // 23: yao.APIResponse.HeadersEntry + (*HeartbeatRequest)(nil), // 21: yao.HeartbeatRequest + (*HeartbeatResponse)(nil), // 22: yao.HeartbeatResponse + nil, // 23: yao.ShellRequest.EnvEntry + nil, // 24: yao.APIRequest.HeadersEntry + nil, // 25: yao.APIResponse.HeadersEntry } -var file_yao_proto_depIdxs = []int32{ - 21, // 0: yao.ShellRequest.env:type_name -> yao.ShellRequest.EnvEntry - 22, // 1: yao.APIRequest.headers:type_name -> yao.APIRequest.HeadersEntry - 23, // 2: yao.APIResponse.headers:type_name -> yao.APIResponse.HeadersEntry +var file_grpc_pb_yao_proto_depIdxs = []int32{ + 23, // 0: yao.ShellRequest.env:type_name -> yao.ShellRequest.EnvEntry + 24, // 1: yao.APIRequest.headers:type_name -> yao.APIRequest.HeadersEntry + 25, // 2: yao.APIResponse.headers:type_name -> yao.APIResponse.HeadersEntry 0, // 3: yao.Yao.Run:input_type -> yao.RunRequest 0, // 4: yao.Yao.Stream:input_type -> yao.RunRequest 3, // 5: yao.Yao.Shell:input_type -> yao.ShellRequest @@ -1271,46 +1395,48 @@ var file_yao_proto_depIdxs = []int32{ 14, // 13: yao.Yao.ChatCompletionsStream:input_type -> yao.ChatRequest 17, // 14: yao.Yao.AgentStream:input_type -> yao.AgentRequest 19, // 15: yao.Yao.Healthz:input_type -> yao.Empty - 1, // 16: yao.Yao.Run:output_type -> yao.RunResponse - 2, // 17: yao.Yao.Stream:output_type -> yao.Chunk - 4, // 18: yao.Yao.Shell:output_type -> yao.ShellResponse - 2, // 19: yao.Yao.ShellStream:output_type -> yao.Chunk - 6, // 20: yao.Yao.API:output_type -> yao.APIResponse - 8, // 21: yao.Yao.MCPListTools:output_type -> yao.MCPListResponse - 10, // 22: yao.Yao.MCPCallTool:output_type -> yao.MCPCallResponse - 11, // 23: yao.Yao.MCPListResources:output_type -> yao.MCPResourcesResponse - 13, // 24: yao.Yao.MCPReadResource:output_type -> yao.MCPResourceResponse - 15, // 25: yao.Yao.ChatCompletions:output_type -> yao.ChatResponse - 16, // 26: yao.Yao.ChatCompletionsStream:output_type -> yao.ChatChunk - 18, // 27: yao.Yao.AgentStream:output_type -> yao.AgentChunk - 20, // 28: yao.Yao.Healthz:output_type -> yao.HealthzResponse - 16, // [16:29] is the sub-list for method output_type - 3, // [3:16] is the sub-list for method input_type + 21, // 16: yao.Yao.Heartbeat:input_type -> yao.HeartbeatRequest + 1, // 17: yao.Yao.Run:output_type -> yao.RunResponse + 2, // 18: yao.Yao.Stream:output_type -> yao.Chunk + 4, // 19: yao.Yao.Shell:output_type -> yao.ShellResponse + 2, // 20: yao.Yao.ShellStream:output_type -> yao.Chunk + 6, // 21: yao.Yao.API:output_type -> yao.APIResponse + 8, // 22: yao.Yao.MCPListTools:output_type -> yao.MCPListResponse + 10, // 23: yao.Yao.MCPCallTool:output_type -> yao.MCPCallResponse + 11, // 24: yao.Yao.MCPListResources:output_type -> yao.MCPResourcesResponse + 13, // 25: yao.Yao.MCPReadResource:output_type -> yao.MCPResourceResponse + 15, // 26: yao.Yao.ChatCompletions:output_type -> yao.ChatResponse + 16, // 27: yao.Yao.ChatCompletionsStream:output_type -> yao.ChatChunk + 18, // 28: yao.Yao.AgentStream:output_type -> yao.AgentChunk + 20, // 29: yao.Yao.Healthz:output_type -> yao.HealthzResponse + 22, // 30: yao.Yao.Heartbeat:output_type -> yao.HeartbeatResponse + 17, // [17:31] is the sub-list for method output_type + 3, // [3:17] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name 3, // [3:3] is the sub-list for extension extendee 0, // [0:3] is the sub-list for field type_name } -func init() { file_yao_proto_init() } -func file_yao_proto_init() { - if File_yao_proto != nil { +func init() { file_grpc_pb_yao_proto_init() } +func file_grpc_pb_yao_proto_init() { + if File_grpc_pb_yao_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_yao_proto_rawDesc), len(file_yao_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_grpc_pb_yao_proto_rawDesc), len(file_grpc_pb_yao_proto_rawDesc)), NumEnums: 0, - NumMessages: 24, + NumMessages: 26, NumExtensions: 0, NumServices: 1, }, - GoTypes: file_yao_proto_goTypes, - DependencyIndexes: file_yao_proto_depIdxs, - MessageInfos: file_yao_proto_msgTypes, + GoTypes: file_grpc_pb_yao_proto_goTypes, + DependencyIndexes: file_grpc_pb_yao_proto_depIdxs, + MessageInfos: file_grpc_pb_yao_proto_msgTypes, }.Build() - File_yao_proto = out.File - file_yao_proto_goTypes = nil - file_yao_proto_depIdxs = nil + File_grpc_pb_yao_proto = out.File + file_grpc_pb_yao_proto_goTypes = nil + file_grpc_pb_yao_proto_depIdxs = nil } diff --git a/grpc/pb/yao.proto b/grpc/pb/yao.proto index f120151f..dfe8abfe 100644 --- a/grpc/pb/yao.proto +++ b/grpc/pb/yao.proto @@ -29,6 +29,9 @@ service Yao { // Health rpc Healthz(Empty) returns (HealthzResponse); + + // Sandbox + rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); } // โ”€โ”€ Base โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -147,3 +150,16 @@ message Empty {} message HealthzResponse { string status = 1; } + +// โ”€โ”€ Sandbox โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +message HeartbeatRequest { + string sandbox_id = 1; + int32 cpu_percent = 2; + int64 mem_bytes = 3; + int32 running_procs = 4; +} + +message HeartbeatResponse { + string action = 1; // "ok" or "shutdown" +} diff --git a/grpc/pb/yao_grpc.pb.go b/grpc/pb/yao_grpc.pb.go index db5b07d6..44b48b81 100644 --- a/grpc/pb/yao_grpc.pb.go +++ b/grpc/pb/yao_grpc.pb.go @@ -2,7 +2,7 @@ // versions: // - protoc-gen-go-grpc v1.6.1 // - protoc v4.25.0 -// source: yao.proto +// source: grpc/pb/yao.proto package pb @@ -32,6 +32,7 @@ const ( Yao_ChatCompletionsStream_FullMethodName = "/yao.Yao/ChatCompletionsStream" Yao_AgentStream_FullMethodName = "/yao.Yao/AgentStream" Yao_Healthz_FullMethodName = "/yao.Yao/Healthz" + Yao_Heartbeat_FullMethodName = "/yao.Yao/Heartbeat" ) // YaoClient is the client API for Yao service. @@ -59,6 +60,8 @@ type YaoClient interface { AgentStream(ctx context.Context, in *AgentRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[AgentChunk], error) // Health Healthz(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*HealthzResponse, error) + // Sandbox + Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) } type yaoClient struct { @@ -235,6 +238,16 @@ func (c *yaoClient) Healthz(ctx context.Context, in *Empty, opts ...grpc.CallOpt return out, nil } +func (c *yaoClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HeartbeatResponse) + err := c.cc.Invoke(ctx, Yao_Heartbeat_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // YaoServer is the server API for Yao service. // All implementations must embed UnimplementedYaoServer // for forward compatibility. @@ -260,6 +273,8 @@ type YaoServer interface { AgentStream(*AgentRequest, grpc.ServerStreamingServer[AgentChunk]) error // Health Healthz(context.Context, *Empty) (*HealthzResponse, error) + // Sandbox + Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) mustEmbedUnimplementedYaoServer() } @@ -309,6 +324,9 @@ func (UnimplementedYaoServer) AgentStream(*AgentRequest, grpc.ServerStreamingSer func (UnimplementedYaoServer) Healthz(context.Context, *Empty) (*HealthzResponse, error) { return nil, status.Error(codes.Unimplemented, "method Healthz not implemented") } +func (UnimplementedYaoServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Heartbeat not implemented") +} func (UnimplementedYaoServer) mustEmbedUnimplementedYaoServer() {} func (UnimplementedYaoServer) testEmbeddedByValue() {} @@ -536,6 +554,24 @@ func _Yao_Healthz_Handler(srv interface{}, ctx context.Context, dec func(interfa return interceptor(ctx, in, info, handler) } +func _Yao_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(HeartbeatRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(YaoServer).Heartbeat(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Yao_Heartbeat_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(YaoServer).Heartbeat(ctx, req.(*HeartbeatRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Yao_ServiceDesc is the grpc.ServiceDesc for Yao service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -579,6 +615,10 @@ var Yao_ServiceDesc = grpc.ServiceDesc{ MethodName: "Healthz", Handler: _Yao_Healthz_Handler, }, + { + MethodName: "Heartbeat", + Handler: _Yao_Heartbeat_Handler, + }, }, Streams: []grpc.StreamDesc{ { @@ -602,5 +642,5 @@ var Yao_ServiceDesc = grpc.ServiceDesc{ ServerStreams: true, }, }, - Metadata: "yao.proto", + Metadata: "grpc/pb/yao.proto", } diff --git a/grpc/sandbox/heartbeat.go b/grpc/sandbox/heartbeat.go new file mode 100644 index 00000000..a9ece76c --- /dev/null +++ b/grpc/sandbox/heartbeat.go @@ -0,0 +1,76 @@ +package sandbox + +import ( + "context" + "sync" + "time" + + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/grpc/pb" +) + +// HeartbeatData holds the latest heartbeat from a sandbox container. +type HeartbeatData struct { + SandboxID string + CPUPercent int32 + MemBytes int64 + RunningProcs int32 + LastSeen time.Time +} + +// Handler implements sandbox-related gRPC methods. +type Handler struct { + mu sync.RWMutex + heartbeats map[string]*HeartbeatData + onBeat func(data *HeartbeatData) string // optional callback; returns action +} + +// NewHandler creates a Handler. onBeat is called on each heartbeat and +// may return "ok" or "shutdown" to signal the container. +func NewHandler(onBeat func(data *HeartbeatData) string) *Handler { + return &Handler{ + heartbeats: make(map[string]*HeartbeatData), + onBeat: onBeat, + } +} + +// Heartbeat handles the Heartbeat RPC from sandbox containers. +func (h *Handler) Heartbeat(_ context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { + data := &HeartbeatData{ + SandboxID: req.SandboxId, + CPUPercent: req.CpuPercent, + MemBytes: req.MemBytes, + RunningProcs: req.RunningProcs, + LastSeen: time.Now(), + } + + h.mu.Lock() + h.heartbeats[req.SandboxId] = data + h.mu.Unlock() + + action := "ok" + if h.onBeat != nil { + if a := h.onBeat(data); a != "" { + action = a + } + } + + log.Trace("sandbox heartbeat: id=%s cpu=%d%% mem=%d procs=%d โ†’ %s", + req.SandboxId, req.CpuPercent, req.MemBytes, req.RunningProcs, action) + + return &pb.HeartbeatResponse{Action: action}, nil +} + +// LastHeartbeat returns the most recent heartbeat for a sandbox, or nil. +func (h *Handler) LastHeartbeat(sandboxID string) *HeartbeatData { + h.mu.RLock() + defer h.mu.RUnlock() + return h.heartbeats[sandboxID] +} + +// RemoveHeartbeat cleans up heartbeat data for a removed sandbox. +func (h *Handler) RemoveHeartbeat(sandboxID string) { + h.mu.Lock() + delete(h.heartbeats, sandboxID) + h.mu.Unlock() +} diff --git a/grpc/sandbox/heartbeat_test.go b/grpc/sandbox/heartbeat_test.go new file mode 100644 index 00000000..028bc5be --- /dev/null +++ b/grpc/sandbox/heartbeat_test.go @@ -0,0 +1,158 @@ +package sandbox + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/yaoapp/yao/grpc/pb" +) + +func TestHeartbeat_StoresData(t *testing.T) { + h := NewHandler(nil) + + req := &pb.HeartbeatRequest{ + SandboxId: "sb-1", + CpuPercent: 25, + MemBytes: 1024 * 1024, + RunningProcs: 3, + } + resp, err := h.Heartbeat(context.Background(), req) + if err != nil { + t.Fatalf("Heartbeat: %v", err) + } + if resp.Action != "ok" { + t.Errorf("action = %q, want %q", resp.Action, "ok") + } + + data := h.LastHeartbeat("sb-1") + if data == nil { + t.Fatal("LastHeartbeat returned nil") + } + if data.SandboxID != "sb-1" { + t.Errorf("SandboxID = %q", data.SandboxID) + } + if data.CPUPercent != 25 { + t.Errorf("CPUPercent = %d", data.CPUPercent) + } + if data.MemBytes != 1024*1024 { + t.Errorf("MemBytes = %d", data.MemBytes) + } + if data.RunningProcs != 3 { + t.Errorf("RunningProcs = %d", data.RunningProcs) + } + if time.Since(data.LastSeen) > time.Second { + t.Errorf("LastSeen too old: %v", data.LastSeen) + } +} + +func TestHeartbeat_OnBeatCallback(t *testing.T) { + var received *HeartbeatData + h := NewHandler(func(d *HeartbeatData) string { + received = d + return "shutdown" + }) + + resp, err := h.Heartbeat(context.Background(), &pb.HeartbeatRequest{ + SandboxId: "sb-2", + CpuPercent: 90, + MemBytes: 4096, + RunningProcs: 10, + }) + if err != nil { + t.Fatalf("Heartbeat: %v", err) + } + if resp.Action != "shutdown" { + t.Errorf("action = %q, want %q", resp.Action, "shutdown") + } + if received == nil || received.SandboxID != "sb-2" { + t.Errorf("callback not invoked or wrong data") + } +} + +func TestHeartbeat_OnBeatEmptyReturnDefaultsToOK(t *testing.T) { + h := NewHandler(func(d *HeartbeatData) string { + return "" + }) + + resp, err := h.Heartbeat(context.Background(), &pb.HeartbeatRequest{SandboxId: "sb-3"}) + if err != nil { + t.Fatalf("Heartbeat: %v", err) + } + if resp.Action != "ok" { + t.Errorf("action = %q, want %q", resp.Action, "ok") + } +} + +func TestLastHeartbeat_Unknown(t *testing.T) { + h := NewHandler(nil) + if d := h.LastHeartbeat("nonexistent"); d != nil { + t.Errorf("expected nil for unknown sandbox, got %+v", d) + } +} + +func TestRemoveHeartbeat(t *testing.T) { + h := NewHandler(nil) + + h.Heartbeat(context.Background(), &pb.HeartbeatRequest{SandboxId: "sb-rm"}) + if h.LastHeartbeat("sb-rm") == nil { + t.Fatal("expected data after heartbeat") + } + + h.RemoveHeartbeat("sb-rm") + if h.LastHeartbeat("sb-rm") != nil { + t.Error("expected nil after RemoveHeartbeat") + } +} + +func TestRemoveHeartbeat_Idempotent(t *testing.T) { + h := NewHandler(nil) + h.RemoveHeartbeat("never-existed") +} + +func TestHeartbeat_ConcurrentAccess(t *testing.T) { + h := NewHandler(nil) + var wg sync.WaitGroup + + for i := 0; i < 50; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + id := "sb-concurrent" + h.Heartbeat(context.Background(), &pb.HeartbeatRequest{ + SandboxId: id, + CpuPercent: int32(n), + RunningProcs: int32(n), + }) + h.LastHeartbeat(id) + }(i) + } + wg.Wait() + + if d := h.LastHeartbeat("sb-concurrent"); d == nil { + t.Error("expected data after concurrent heartbeats") + } +} + +func TestHeartbeat_MultiSandbox(t *testing.T) { + h := NewHandler(nil) + + for _, id := range []string{"a", "b", "c"} { + h.Heartbeat(context.Background(), &pb.HeartbeatRequest{SandboxId: id, CpuPercent: 10}) + } + + for _, id := range []string{"a", "b", "c"} { + if d := h.LastHeartbeat(id); d == nil { + t.Errorf("missing heartbeat for %q", id) + } + } + + h.RemoveHeartbeat("b") + if h.LastHeartbeat("b") != nil { + t.Error("b should be removed") + } + if h.LastHeartbeat("a") == nil || h.LastHeartbeat("c") == nil { + t.Error("a and c should still exist") + } +} diff --git a/sandbox/DESIGN.md b/sandbox/DESIGN.md index 19fc29fd..4c1ee68c 100644 --- a/sandbox/DESIGN.md +++ b/sandbox/DESIGN.md @@ -45,23 +45,22 @@ High-level business layer on top of `tai.Client`. Manages container lifecycle, u - File operations (via `tai.Client.Volume()` for remote, bind mount for local) - IPC relay to Yao gRPC server -### Yao gRPC Server (yao/grpc) +### Yao gRPC Server (yao/grpc) โ€” โœ… Implemented -General-purpose gRPC service exposed by the Yao process. Not limited to sandbox IPC โ€” it exposes Yao's process execution capability to any gRPC client. +General-purpose gRPC gateway exposed by the Yao process. Not limited to sandbox IPC โ€” it exposes process execution, shell, API proxy, MCP, LLM, and Agent capabilities to any gRPC client. 14 RPCs defined; V1 (unary + LLM/Agent streaming) complete, V2 (base streaming via `gou/stream`) pending. **Clients:** -- Container-internal MCP tools (via Tai Gateway relay) -- `yao run --remote` CLI +- Container-internal `yao-grpc` (via Tai Gateway relay or direct) +- `yao run` CLI (after `yao login`) - Other Yao instances (future node-to-node) **IPC path (replacing Unix socket):** ``` -Container process โ†’ yao-bridge (tai/bridge/) โ†’ Tai Gateway (:9100 gRPC) โ†’ Yao gRPC Server (:9099) - โ”‚ - process.Run(...) +Local: Container โ†’ yao-grpc (tai/grpc/) โ†’ Yao gRPC 127.0.0.1:9099 +Remote: Container โ†’ yao-grpc (tai/grpc/) โ†’ Tai Gateway (:9100 gRPC) โ†’ Yao gRPC Server (:9099) ``` -Tai does **not** know Yao gRPC address at startup. The upstream is passed per-container via `CreateRequest.GRPCUpstream` โ€” Tai records the mapping and routes relay traffic by source container. This keeps Tai stateless and allows one Tai to serve multiple Yao instances. +All modes use gRPC โ€” no Unix socket fallback. `yao-grpc` reads `YAO_GRPC_ADDR` from env and connects. Local containers point directly at the Yao gRPC server on loopback; remote containers point at the Tai relay. Tai does **not** know Yao gRPC address at startup โ€” `yao-grpc` carries target in `x-grpc-upstream` request metadata. This keeps Tai stateless and allows one Tai to serve multiple Yao instances. ## Authentication @@ -79,11 +78,11 @@ The gRPC server reuses the existing `openapi/oauth` service โ€” no new auth syst | Scope registration | `acl.Register(...)` | gRPC scopes via same pattern | None โ€” add `grpc:*` scope definitions in `init()` | | Client auth | `ClientProvider` | `client_credentials` grant for CLI/containers | None | | Token revocation | `oauth.Revoke(ctx, token, hint)` | Container token cleanup | None | -| Device Flow scaffolding | `types.DeviceAuthorizationResponse`, `GrantTypeDeviceCode`, error codes | CLI `yao login` | Implement `DeviceAuthorization()` (currently stub) | +| Device Flow | `DeviceAuthorization()`, `AuthorizeDevice()`, device_code store, `GrantTypeDeviceCode` grant | CLI `yao login` | โœ… Implemented | **Key insight**: `authorized.SetInfo/GetInfo` are Gin-bound, but gRPC does NOT need them. The gRPC interceptor builds `AccessRequest` directly from JWT claims and calls `ScopeManager.Check` โ€” bypasses the full `Enforce` chain (client/team/member), which is HTTP multi-tenant only. -**Impact on existing code: zero.** All gRPC auth is purely additive (~80 lines interceptor + scope registration). Device Flow adds ~190 lines new code + ~10 lines to existing `Token()` switch. +**Status**: All auth infrastructure is implemented and working โ€” gRPC interceptor, scope registration, Device Flow (backend + CUI page), CLI commands (`yao login`/`yao logout`). ### gRPC interceptor @@ -106,8 +105,8 @@ func authInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, h | Client | How it gets a token | |--------|-------------------| -| Container MCP tool | `oauth.MakeAccessToken(clientID, "grpc:mcp grpc:run", userID, 900)` injected as `YAO_TOKEN` env var. `yao-bridge` auto-refreshes via `YAO_REFRESH_TOKEN`. Manager revokes refresh token on container Remove. | -| `yao run` CLI | `yao login` โ†’ OAuth Device Authorization Grant โ†’ token saved to `~/.yao/credentials`. Logged in = gRPC, not logged in = local. | +| Container MCP tool | `oauth.MakeAccessToken(clientID, "grpc:mcp grpc:run", userID, 900)` injected as `YAO_TOKEN` env var. `yao-grpc` auto-refreshes via response metadata. Manager revokes refresh token on container Remove. | +| `yao run` CLI | โœ… `yao login --server ` โ†’ OAuth Device Authorization Grant (RFC 8628) โ†’ dynamic client registration via machine ID โ†’ token saved to `~/.yao/credentials` (base64 JSON). Logged in = gRPC, not logged in = local. | | Yao-to-Yao | Pre-shared service token or `client_credentials` | ## Network Security @@ -193,7 +192,7 @@ Lifecycle is managed by `sandbox.Manager`, not by tai.Client. | Mode | tai.Client | File IO | |------|-----------|---------| -| Local | `tai.New("")` | Bind mount, direct host filesystem | +| Local | `tai.New("local")` | Bind mount, direct host filesystem | | Remote | `tai.New("tai://host")` | `tai.Client.Volume()` via gRPC | Local mode preserves bind mount for performance. Remote mode uses `tai/volume` (gRPC + lz4 compression). `sandbox.Manager` routes based on `client.IsLocal()`. @@ -234,32 +233,37 @@ agent/context/jsapi_sandbox.go | Container exec | `dockerClient.ContainerExecCreate/Start/Attach` | `tai.Client.Sandbox().Exec()` | | File read | Host path via bind mount (`containerPathToHost`) | Local: bind mount (same). Remote: `tai.Client.Volume().Read()` | | File write | `dockerClient.CopyToContainer` | Local: bind mount. Remote: `tai.Client.Volume().Write()` | -| IPC | Unix socket bind mount + yao-bridge | Local: Unix socket (same). Remote: Tai gRPC relay โ†’ Yao gRPC server | -| MCP config | `{args: ["/tmp/yao.sock"]}` hardcoded | Local: socket path from config. Remote: gRPC endpoint injected as env var | +| IPC | Unix socket bind mount + yao-bridge | All modes: `yao-grpc` โ†’ gRPC (direct or via Tai relay). No Unix socket. | +| MCP config | `{args: ["/tmp/yao.sock"]}` hardcoded | `YAO_GRPC_ADDR` + `YAO_TOKEN` env vars. Local: direct. Remote: + `YAO_GRPC_TAI`/`YAO_GRPC_UPSTREAM`. | | VNC | `vncproxy.NewProxy(nil)` local assumption | `tai.Client.VNC().URL()` | | Cleanup | `dockerClient.ContainerRemove` | `tai.Client.Sandbox().Remove()` | ### IPC migration detail -**Local mode** (same host): Unix socket preserved โ€” zero overhead, no change needed. - -**Remote mode** (via Tai): -``` -Container process โ†’ yao-bridge (tai/bridge/) โ†’ Tai relay (:9100 gRPC) โ†’ Yao gRPC Server -``` - -`yao-bridge` source lives in `yao/tai/bridge/` โ€” it's a Tai SDK client (consumes Tai relay), shares gRPC deps with `tai/`, and is version-locked with the Tai protocol. Built via `go build ./tai/bridge/cmd/yao-bridge`. - -Bridge mode determined by env var: +All modes use gRPC โ€” no Unix socket fallback, one code path for local and remote. ``` -YAO_IPC_MODE=socket YAO_IPC_ADDR=/tmp/yao.sock # local -YAO_IPC_MODE=grpc YAO_IPC_ADDR=tai-host:9100 # remote +Local: Container โ†’ yao-grpc โ†’ Yao gRPC 127.0.0.1:9099 +Remote: Container โ†’ yao-grpc โ†’ Tai :9100 relay โ†’ Yao gRPC :9099 ``` -In gRPC mode, bridge also reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` and handles automatic token refresh (see grpc/DESIGN.md Container token section). +`yao-grpc` source lives in `yao/tai/grpc/` โ€” shares gRPC deps with `tai/`, version-locked with the Tai protocol. Built via `go build -o yao-grpc ./tai/grpc/cmd`. -Tai relay upstream is NOT configured at Tai startup. Manager passes `GRPCUpstream` per-container in `CreateRequest` โ€” Tai records the mapping and routes by source container. One Tai can serve containers from different Yao instances. +Mode determined by env vars injected by Manager at container creation: + +``` +# Local: direct to Yao +YAO_GRPC_ADDR=127.0.0.1:9099 + +# Remote: via Tai relay +YAO_GRPC_ADDR=tai-host:9100 +YAO_GRPC_TAI=enable +YAO_GRPC_UPSTREAM=yao-host:9099 +``` + +`yao-grpc` reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` from env, attaches as gRPC metadata on every call, and handles automatic token refresh from response metadata. In Tai relay mode, attaches `x-grpc-upstream` metadata so Tai knows where to forward. + +Tai relay upstream is NOT configured at Tai startup. `yao-grpc` carries the target address per request โ€” Tai reads `x-grpc-upstream` metadata and proxies dynamically. One Tai can serve containers from different Yao instances. `BuildMCPConfigForSandbox()` sets the env vars based on `client.IsLocal()`. @@ -298,8 +302,26 @@ sandbox: ## Migration Path -1. **Phase 1:** Yao gRPC server โ€” expose process execution, replace Unix socket IPC -2. **Phase 2:** `sandbox.Manager` refactoring โ€” replace Docker client with `tai.Client`, unified file ops, new lifecycle model -3. **Phase 3:** Agent layer adaptation โ€” executor uses new Manager, IPC mode switch, lifecycle policy -4. **Phase 4:** `yao run --remote` โ€” CLI calls remote Yao via gRPC -5. **Phase 5:** Workspace persistence โ€” browser preview, service exposure, delivery +### Completed + +1. **Yao gRPC server** (`yao/grpc`) โ€” full gRPC gateway with 14 RPCs (Run, Stream, Shell, ShellStream, API, MCPร—4, ChatCompletions, ChatCompletionsStream, AgentStream, Healthz). OAuth + ACL auth interceptor reusing existing openapi infrastructure. V1 all unary + LLM/Agent streaming done; V2 base streaming (Stream, ShellStream) pending `gou/stream` package. Details: [grpc/DESIGN.md](../grpc/DESIGN.md), [grpc/IMPL.md](../grpc/IMPL.md). + +2. **Tai SDK** (`yao/tai`) โ€” unified sandbox runtime SDK with Local/Remote modes. Sandbox (container lifecycle), Volume (file IO + sync with lz4), Workspace (`fs.FS` compatible), Proxy (HTTP reverse proxy), VNC (WebSocket). Remote mode connects via Tai gateway (gRPC :9100, Docker :2375, K8s :6443, HTTP :8080, VNC :6080). Details: [tai/docs/README.md](../tai/docs/README.md). + +3. **Tai gateway dynamic routing** (Tai repo) โ€” removed fixed `YaoUpstream` startup config. `yao-grpc` carries `x-grpc-upstream` metadata per request; Tai reads target and proxies dynamically. One Tai serves containers from multiple Yao instances. + +4. **yao-grpc container client** (`yao/tai/grpc`) โ€” in-container gRPC client binary replacing `yao-bridge`. Reads `YAO_TOKEN`/`YAO_REFRESH_TOKEN`/`YAO_SANDBOX_ID` from env, auto-refreshes tokens via response metadata. Supports direct mode (`YAO_GRPC_ADDR=127.0.0.1:9099`) and Tai relay mode (`YAO_GRPC_TAI=enable`). Built as `go build -o yao-grpc ./tai/grpc/cmd`. + +5. **OAuth Device Flow + CLI auth** โ€” `yao login --server ` (RFC 8628 Device Authorization Grant), `yao logout`, credentials stored as base64 JSON in `~/.yao/credentials`. CUI `/auth/device` page for user authorization. Dynamic client registration via machine ID. + +6. **`yao run` via gRPC** (`yao/cmd/run.go`) โ€” no `--remote` flag; logged in = gRPC, not logged in = local. `--auth ` for alternate credentials. TUI status bar (lipgloss) shows user/scope in gRPC mode, hidden with `-s` (silent). + +### Remaining + +7. **`sandbox.Manager` refactoring** โ€” replace Docker client with `tai.Client`, unified file ops (bind mount for local, `tai.Client.Volume()` for remote), new lifecycle model (one-shot / session / long-running / persistent). + +8. **Agent layer adaptation** โ€” executor uses new Manager, IPC mode switch (gRPC replaces Unix socket), lifecycle policy per-assistant config. + +9. **`gou/stream` package** (V2) โ€” streaming process execution foundation. ~150 lines. Enables gRPC `Stream` and `ShellStream` handlers, V8 `Stream()` global. + +10. **Workspace persistence** โ€” browser preview, service exposure, delivery. diff --git a/sandbox/docker/build.sh b/sandbox/docker/build.sh index 2e3ea74b..c5a3077f 100755 --- a/sandbox/docker/build.sh +++ b/sandbox/docker/build.sh @@ -164,15 +164,21 @@ case $TOOL in # Cursor (uncomment when ready) # build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH" ;; + v2) + echo "V2 images have their own build script: sandbox/v2/docker/build.sh" + echo "Usage: sandbox/v2/docker/build.sh [true|false]" + exit 0 + ;; *) echo "Unknown tool: $TOOL" - echo "Usage: $0 [claude|claude-vnc|browser|desktop|chrome|cursor|all] [true|false]" + echo "Usage: $0 [claude|claude-vnc|browser|desktop|chrome|cursor|v2|all] [true|false]" echo " $0 claude # Build Claude images locally" echo " $0 claude true # Build and push Claude images" echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)" echo " $0 browser # Build Claude Browser image only" echo " $0 desktop # Build Claude Desktop image only" echo " $0 chrome # Build Claude Chrome image (amd64 only)" + echo " $0 v2 # Build Sandbox V2 images (base + test)" echo " $0 all true # Build and push all images" exit 1 ;; diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md new file mode 100644 index 00000000..43508486 --- /dev/null +++ b/sandbox/v2/DESIGN.md @@ -0,0 +1,1132 @@ +# Sandbox V2 Design + +## Positioning + +Sandbox is a **standalone infrastructure module** in Yao, on the same level as `process`, `store`, and `fs`. It provides isolated execution environments with standard file I/O. Any module can use it โ€” Agent, JSAPI scripts, Process handlers, API endpoints. + +``` +Yao Infrastructure +โ”œโ”€โ”€ process โ€” process execution +โ”œโ”€โ”€ store โ€” KV storage +โ”œโ”€โ”€ fs โ€” host filesystem +โ”œโ”€โ”€ stream โ€” streaming execution (planned) +โ”œโ”€โ”€ workspace โ€” persistent user storage โ† new in V2 +โ””โ”€โ”€ sandbox โ€” isolated execution environments โ† this module +``` + +Sandbox does NOT import or depend on Agent. Agent is one of many consumers. + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Consumers (know nothing about tai/Docker/K8s) โ”‚ +โ”‚ โ”œโ”€โ”€ JSAPI: sandbox.Create/Get/List/Delete โ”‚ +โ”‚ โ”œโ”€โ”€ JSAPI: workspace.Create/Get/List/Delete โ”‚ +โ”‚ โ”œโ”€โ”€ Process: sandbox.Create, sandbox.Exec โ”‚ +โ”‚ โ”œโ”€โ”€ Agent: uses sandbox via interface โ”‚ +โ”‚ โ””โ”€โ”€ API: /api/__yao/sandbox/* โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ sandbox.Manager (public API) + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ sandbox/v2 โ”‚ +โ”‚ โ”‚ +โ”‚ Manager (global singleton) โ”‚ +โ”‚ โ”œโ”€โ”€ Create / Get / GetOrCreate / List / Remove โ”‚ +โ”‚ โ”œโ”€โ”€ Start / Cleanup / Close โ”‚ +โ”‚ โ”œโ”€โ”€ Heartbeat (idle tracking) โ”‚ +โ”‚ โ”œโ”€โ”€ AddPool / RemovePool / Pools โ”‚ +โ”‚ โ”œโ”€โ”€ SetWorkspaceManager (workspace integration)โ”‚ +โ”‚ โ”œโ”€โ”€ EnsureImage / ImageExists / PullImage โ”‚ +โ”‚ โ””โ”€โ”€ guard rails (limits, TTL) + Box factory โ”‚ +โ”‚ โ”‚ +โ”‚ Box (per-instance) โ”‚ +โ”‚ โ”œโ”€โ”€ Exec(cmd) โ†’ ExecResult โ”‚ +โ”‚ โ”œโ”€โ”€ Stream(cmd) โ†’ ExecStream (real-time I/O) โ”‚ +โ”‚ โ”œโ”€โ”€ Attach(port) โ†’ ServiceConn (WS/SSE) โ”‚ +โ”‚ โ”œโ”€โ”€ Workspace() โ†’ workspace.FS โ”‚ +โ”‚ โ”œโ”€โ”€ VNC() โ†’ url โ”‚ +โ”‚ โ”œโ”€โ”€ Proxy(port) โ†’ url โ”‚ +โ”‚ โ””โ”€โ”€ Start / Stop / Remove / Info โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ tai.Client pool (lazy-initialized) โ”‚ +โ”‚ โ”œโ”€โ”€ "local" โ†’ tai.New("local") (Docker) โ”‚ +โ”‚ โ”œโ”€โ”€ "gpu" โ†’ tai.New("tai://gpu") (Remote) โ”‚ +โ”‚ โ”œโ”€โ”€ "k8s" โ†’ tai.New("tai://k8s",K8s)(K8s) โ”‚ +โ”‚ โ””โ”€โ”€ ... โ”‚ +โ”‚ โ”‚ +โ”‚ Each tai.Client provides: โ”‚ +โ”‚ โ”œโ”€โ”€ Sandbox() โ†’ CRUD + Exec + ExecStream โ”‚ +โ”‚ โ”œโ”€โ”€ Image() โ†’ Exists + Pull + Remove + Listโ”‚ +โ”‚ โ”œโ”€โ”€ Volume() โ†’ file I/O (local disk / gRPC) โ”‚ +โ”‚ โ”œโ”€โ”€ Workspace() โ†’ fs.FS โ”‚ +โ”‚ โ”œโ”€โ”€ Proxy() โ†’ URL resolve + Connect โ”‚ +โ”‚ โ””โ”€โ”€ VNC() โ†’ VNC WebSocket โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Dependency Rules + +``` +sandbox/v2 โ†’ tai โœ“ (sole runtime dependency) +sandbox/v2 โ†’ workspace โœ“ (workspace integration, optional) +sandbox/v2 โ†’ agent โœ— NEVER +sandbox/v2 โ†’ docker โœ— NEVER (tai handles it) +agent โ†’ sandbox/v2 โœ“ (consumer, via Manager API) +jsapi โ†’ sandbox/v2 โœ“ (consumer, via Manager API) +process โ†’ sandbox/v2 โœ“ (consumer, via Manager API) +``` + +## Manager + +Global singleton. Manages a **pool of named `tai.Client` connections** โ€” each pool entry targets a different runtime endpoint (local Docker, remote Tai, K8s cluster). Caller picks which pool to create a sandbox on. + +### Pool + +```go +type Pool struct { + Name string + Addr string // tai.New() address: "local", "tai://host", "docker:///path" + Options []tai.Option // tai.K8s, tai.WithKubeConfig(), tai.WithPorts(), etc. + MaxPerUser int // max boxes per user on this pool, 0 = unlimited + MaxTotal int // max boxes total on this pool, 0 = unlimited + IdleTimeout time.Duration // 0 = no timeout + MaxLifetime time.Duration // 0 = no limit + StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout (2s) +} +``` + +Example configuration: + +``` +pool: + - name: local + addr: "local" + max_total: 20 + idle_timeout: 30m + + - name: gpu + addr: "tai://gpu-server.internal" + max_per_user: 1 + max_total: 4 + idle_timeout: 10m + max_lifetime: 2h + + - name: k8s + addr: "tai://k8s-proxy.internal" + max_total: 100 + idle_timeout: 1h + options: + runtime: k8s + kubeconfig: /etc/yao/kubeconfig.yml +``` + +### Initialization + +```go +var mgr *Manager + +func Init(cfg Config) error // create Manager from Config; at least one Pool required +func M() *Manager // return global singleton; panics if Init not called +``` + +Startup sequence in `cmd/start.go`: + +``` +config.Load +sandbox.Init(config.Conf.Sandbox) // create Manager with pool + guard rails +engine.Load +... +service.Start // HTTP +grpc.Start // gRPC +sandbox.M().Start(ctx) // discover existing containers, start cleanup loop +``` + +`Init` creates the Manager from config (pool definitions + guard rails). `Start` connects to pools, discovers existing containers, and starts the cleanup loop. Two-step so that gRPC server is ready before Start. + +Pool connections are created lazily on first use and reused across all Box instances. + +### Config + +```go +type Config struct { + Pool []Pool +} +``` + +Container gRPC env vars (`YAO_GRPC_ADDR`, `YAO_GRPC_UPSTREAM`, etc.) are derived automatically at creation time. Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions`. + +### Core API + +```go +type Manager struct { + pool map[string]*tai.Client // name โ†’ connection (lazy-initialized) + poolDefs []Pool + defaultPool string // first pool name + config Config + boxes sync.Map // id โ†’ *Box + mu sync.Mutex + cancel context.CancelFunc + grpcPort int + wsManager *workspace.Manager // optional workspace integration +} + +// --- Bootstrap --- +func (m *Manager) Start(ctx context.Context) error +func (m *Manager) Close() error +func (m *Manager) SetGRPCPort(port int) +func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) + +// --- Pool management --- +func (m *Manager) AddPool(ctx context.Context, p Pool) error +func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error +func (m *Manager) Pools() []PoolInfo + +// --- Heartbeat --- +func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error + +// --- CRUD --- +func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) +func (m *Manager) Get(ctx context.Context, id string) (*Box, error) +func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error) +func (m *Manager) List(ctx context.Context, opts ListOptions) ([]*Box, error) +func (m *Manager) Remove(ctx context.Context, id string) error +func (m *Manager) Cleanup(ctx context.Context) error + +// --- Image management --- +func (m *Manager) ImageExists(ctx context.Context, pool, ref string) (bool, error) +func (m *Manager) PullImage(ctx context.Context, pool, ref string, opts ImagePullOptions) (<-chan PullProgress, error) +func (m *Manager) EnsureImage(ctx context.Context, pool, ref string, opts ImagePullOptions) error +``` + +### CreateOptions + +```go +type CreateOptions struct { + ID string + Owner string + Labels map[string]string + Pool string // which tai.Client to use; empty = default pool + + // Container spec + Image string // required + WorkDir string // default "/workspace" + User string + Env map[string]string + Memory int64 // bytes, 0 = no limit + CPUs float64 // 0 = no limit + VNC bool + Ports []PortMapping + + // Lifecycle + Policy LifecyclePolicy // default: Session + IdleTimeout time.Duration // override pool default; 0 = use pool default + StopTimeout time.Duration // SIGTERM grace period; 0 = pool default or DefaultStopTimeout + + // Workspace integration + WorkspaceID string // workspace to mount; empty = no workspace + MountMode string // "rw" (default) or "ro" + MountPath string // container path; default "/workspace" +} +``` + +When `WorkspaceID` is set, the Manager resolves the workspace's bound node via `workspace.Manager.NodeForWorkspace()` and forces the container onto that node. The workspace directory is bind-mounted into the container at `MountPath`. + +### LifecyclePolicy + +```go +type LifecyclePolicy string + +const ( + OneShot LifecyclePolicy = "oneshot" // destroyed after first Exec + Session LifecyclePolicy = "session" // alive while active, cleaned on idle + LongRunning LifecyclePolicy = "longrunning" // user workspace, extended TTL + Persistent LifecyclePolicy = "persistent" // never auto-cleaned +) + +const DefaultStopTimeout = 2 * time.Second +``` + +## Box + +A `Box` is a single sandbox instance. All operations go through it. + +```go +type Box struct { + id string + containerID string + pool string + owner string + policy LifecyclePolicy + labels map[string]string + lastCall atomic.Int64 // last external API call + lastHeartbeat atomic.Int64 // last container heartbeat + processCount atomic.Int32 // user processes inside container + idleTimeoutD time.Duration + stopTimeoutD time.Duration + createdAt time.Time + refreshToken string + vnc bool + image string + workspaceID string + ws workspace.FS // lazy-initialized, cached + manager *Manager +} + +// --- Identity --- +func (b *Box) ID() string +func (b *Box) Owner() string +func (b *Box) ContainerID() string +func (b *Box) Pool() string +func (b *Box) WorkspaceID() string + +// --- Execution --- +func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) +func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) +func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error) + +// --- Filesystem --- +func (b *Box) Workspace() workspace.FS + +// --- Network --- +func (b *Box) VNC(ctx context.Context) (string, error) +func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) + +// --- Lifecycle --- +func (b *Box) Start(ctx context.Context) error +func (b *Box) Stop(ctx context.Context) error +func (b *Box) Remove(ctx context.Context) error +func (b *Box) Info(ctx context.Context) (*BoxInfo, error) +``` + +### ExecOption / ExecResult / ExecStream + +```go +type ExecOption func(*execConfig) + +func WithWorkDir(dir string) ExecOption +func WithEnv(env map[string]string) ExecOption +func WithTimeout(d time.Duration) ExecOption + +type ExecResult struct { + ExitCode int + Stdout string + Stderr string +} + +type ExecStream struct { + Stdout io.ReadCloser + Stderr io.ReadCloser + Stdin io.WriteCloser + Wait func() (int, error) // block until exit, return exit code + Cancel func() // kill the process +} +``` + +### AttachOption / ServiceConn + +```go +type AttachOption func(*attachConfig) + +func WithProtocol(proto string) AttachOption // "ws", "sse"; default "ws" +func WithPath(path string) AttachOption +func WithHeaders(h map[string]string) AttachOption + +type ServiceConn struct { + Read func() ([]byte, error) // read next message (WS mode) + Write func(data []byte) error + Events <-chan []byte // SSE event channel + URL string + Close func() error +} +``` + +`port` is the port the service listens on **inside the container**. Routing โ€” Docker port mapping (local) or Tai HTTP proxy (remote) โ€” is handled internally. + +### Image Management + +```go +type ImagePullOptions struct { + Auth *RegistryAuth +} + +type RegistryAuth struct { + Username string + Password string + Server string +} +``` + +`EnsureImage` first checks `ImageExists`; if not present, calls `PullImage` and blocks until complete. For K8s pools this is a no-op โ€” kubelet manages image pulling natively via `imagePullPolicy`. + +### BoxInfo / PoolInfo + +```go +type BoxInfo struct { + ID string + ContainerID string + Pool string + Owner string + Status string // "running", "stopped", "creating" + Policy LifecyclePolicy + Labels map[string]string + Image string + CreatedAt time.Time + LastActive time.Time + ProcessCount int + VNC bool +} + +type PoolInfo struct { + Name string + Addr string + Connected bool + Boxes int + MaxPerUser int + MaxTotal int + IdleTimeout time.Duration + MaxLifetime time.Duration +} +``` + +## Workspace Integration + +Sandbox V2 integrates with the workspace module via `Manager.SetWorkspaceManager()` and `CreateOptions.WorkspaceID`: + +```go +// Link workspace manager at startup +sbm.SetWorkspaceManager(wsm) + +// Create sandbox with workspace mount +box, err := sbm.Create(ctx, sandbox.CreateOptions{ + Image: "yaoapp/workspace:latest", + WorkspaceID: "ws-abc123", + MountMode: "rw", // default + MountPath: "/workspace", // default +}) +``` + +When `WorkspaceID` is set: +1. Manager calls `workspace.Manager.NodeForWorkspace()` to resolve the workspace's bound node +2. Forces the container onto that node's pool +3. Calls `workspace.Manager.MountPath()` to get the host-side directory +4. Adds a Docker bind mount: `hostPath:mountPath:mode` +5. Box.Workspace() uses the workspace ID as the volume session key + +This guarantees that a workspace's container always runs on the same host where its storage lives. + +## Container Setup โ€” Manager.Create() + +When Manager creates a sandbox, it: + +1. Validates `CreateOptions` (Image required) +2. Generates sandbox ID (or uses provided one) +3. Resolves workspace node binding (if WorkspaceID set) +4. Checks user limits (`MaxPerUser`) and total limits (`MaxTotal`) +5. Resolves pool (by name or default) +6. Creates OAuth token pair for container IPC +7. Builds `tai.sandbox.CreateOptions`: + - Injects management labels: `managed-by`, `sandbox-id`, `sandbox-owner`, `sandbox-pool`, `sandbox-policy`, `workspace-id` + - Sets container CMD to graceful-shutdown-aware sleep: `sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"` + - Merges caller's Env with gRPC env vars (`YAO_SANDBOX_ID`, `YAO_TOKEN`, `YAO_REFRESH_TOKEN`, `YAO_GRPC_ADDR`, etc.) + - Adds workspace bind mount if WorkspaceID is set +8. Calls `tai.Client.Sandbox().Create()` then `Start()` +9. Wraps in a `Box`, registers in `boxes` map + +## Lifecycle Management + +### Idle Tracking โ€” Dual Source + +```go +box.lastActive = max(lastExternalCall, lastHeartbeat) +``` + +| Source | What it tracks | Updated by | +|--------|---------------|------------| +| External call | Caller is using the sandbox | `Box.Exec()`, `Box.Stream()`, `Box.Workspace()`, `Box.VNC()`, `Box.Proxy()`, `Box.Attach()` | +| Container heartbeat | Processes running inside the container | gRPC `Heartbeat` RPC | + +### Cleanup Loop + +Runs every 60 seconds. Policy behavior: + +| Policy | Idle | Max Lifetime | Auto | +|--------|------|-------------|------| +| OneShot | โ€” | โ€” | Removed after first Exec completes | +| Session | Remove | Remove | Default for agent chats | +| LongRunning | Stop (keep data) | Remove | User workspaces | +| Persistent | Never | Never | User-managed | + +### Container Stop Behavior + +`DefaultStopTimeout = 2s`. Docker `ContainerStop` sends SIGTERM, waits the timeout, then SIGKILL. The V2 container CMD (`trap 'exit 0' TERM; ...`) exits immediately on SIGTERM, so actual stop time is near-instant. + +`Manager.Remove()` calls `Sandbox().Remove(force=true)` directly (SIGKILL + delete) โ€” no redundant Stop call. This keeps remove latency under 200ms. + +## Tai SDK Interface + +Sandbox V2 depends on these tai sub-package interfaces: + +### tai.Client + +```go +func New(addr string, opts ...Option) (*Client, error) +func (c *Client) Sandbox() sandbox.Sandbox +func (c *Client) Image() sandbox.Image +func (c *Client) Volume() volume.Volume +func (c *Client) Workspace(sessionID string) workspace.FS +func (c *Client) Proxy() proxy.Proxy +func (c *Client) VNC() vnc.VNC +func (c *Client) DataDir() string +func (c *Client) IsLocal() bool +func (c *Client) Close() error +``` + +Address schemes: `"local"` (Docker default), `"docker://..."` (explicit Docker), `"tai://host"` (remote Tai Server). Remote mode auto-discovers service ports via ServerInfo gRPC, with `WithPorts()` taking precedence. + +### sandbox.Sandbox + +```go +type Sandbox interface { + Create(ctx, opts CreateOptions) (string, error) + Start(ctx, id string) error + Stop(ctx, id string, timeout time.Duration) error + Remove(ctx, id string, force bool) error + Exec(ctx, id string, cmd []string, opts ExecOptions) (*ExecResult, error) + ExecStream(ctx, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) + Inspect(ctx, id string) (*ContainerInfo, error) + List(ctx, opts ListOptions) ([]ContainerInfo, error) + Close() error +} +``` + +Implementations: `docker_core.go` (local Docker), `docker.go` (remote Docker via Tai proxy), `k8s.go` (Kubernetes via Tai proxy). + +### sandbox.Image + +```go +type Image interface { + Exists(ctx, ref string) (bool, error) + Pull(ctx, ref string, opts PullOptions) (<-chan PullProgress, error) + Remove(ctx, ref string, force bool) error + List(ctx) ([]ImageInfo, error) +} +``` + +Docker implementation pulls via Docker SDK with real-time progress streaming. K8s implementation is a no-op โ€” kubelet handles image pulling. + +### proxy.Proxy + +```go +type Proxy interface { + URL(ctx, containerID string, port int, path string) (string, error) + Connect(ctx, containerID string, opts ConnectOptions) (*Connection, error) + Healthz(ctx) error +} +``` + +Local: resolves host ports via `Inspect()`. Remote: routes through Tai HTTP proxy which handles WebSocket upgrade and SSE streaming natively. + +## gRPC Token Injection + +```go +func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) +func RevokeContainerTokens(refresh string) error +func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string +``` + +Environment variables injected into each container: + +``` +# All modes +YAO_SANDBOX_ID= +YAO_TOKEN= +YAO_REFRESH_TOKEN= +YAO_GRPC_ADDR=127.0.0.1:9099 + +# Remote mode (tai://) adds: +YAO_GRPC_TAI=enable +YAO_GRPC_ADDR=:9100 +YAO_GRPC_UPSTREAM=127.0.0.1:9099 +``` + +## Errors + +```go +var ( + ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") + ErrNotFound = errors.New("sandbox: not found") + ErrLimitExceeded = errors.New("sandbox: limit exceeded") + ErrPoolNotFound = errors.New("sandbox: pool not found") + ErrPoolInUse = errors.New("sandbox: pool has running boxes") +) +``` + +## Package Structure + +``` +sandbox/v2/ +โ”œโ”€โ”€ sandbox.go // Init, M(), global singleton +โ”œโ”€โ”€ manager.go // Manager: CRUD, pool management, image ops, cleanup +โ”œโ”€โ”€ box.go // Box: Exec, Stream, Attach, Workspace, VNC, Proxy, lifecycle +โ”œโ”€โ”€ types.go // CreateOptions, ExecResult, ExecStream, ServiceConn, BoxInfo, etc. +โ”œโ”€โ”€ config.go // Config struct +โ”œโ”€โ”€ errors.go // sentinel errors +โ”œโ”€โ”€ grpc.go // token creation/revocation, gRPC env var injection +โ”œโ”€โ”€ jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace +โ”‚ โ”œโ”€โ”€ jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete +โ”‚ โ””โ”€โ”€ box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove +โ”œโ”€โ”€ export_test.go // ResetForTest() for test isolation +โ”œโ”€โ”€ testutils_test.go // shared test helpers (multi-pool setup) +โ”œโ”€โ”€ sandbox_test.go // Init/M singleton tests +โ”œโ”€โ”€ manager_test.go // Manager CRUD tests +โ”œโ”€โ”€ manager_lifecycle_test.go // Heartbeat, Cleanup, idle tracking tests +โ”œโ”€โ”€ box_test.go // Box Exec/Workspace/Info tests +โ”œโ”€โ”€ box_attach_test.go // Attach WS/SSE/VNC tests +โ”œโ”€โ”€ box_workspace_test.go // Workspace integration tests +โ”œโ”€โ”€ box_image_test.go // Image Pull API tests +โ”œโ”€โ”€ bench_test.go // Performance benchmarks +โ”œโ”€โ”€ grpc_test.go // Token/env building tests +โ”œโ”€โ”€ DESIGN.md // this document +โ””โ”€โ”€ IMPL.md // implementation status and plan +``` + +--- + +# Workspace Module + +## Positioning + +Workspace is a **top-level module** (`workspace/`), parallel to `sandbox/v2`. It provides persistent, user-managed storage that is decoupled from container lifecycle. Workspaces are pinned to a specific Tai node; containers referencing a workspace are automatically routed to that node. + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ sandbox/v2 โ”‚ โ”‚ workspace โ”‚ +โ”‚ (container runtime) โ”‚โ—„โ”€โ”€โ”€โ”€โ”‚ (persistent storage)โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ CreateOptions { โ”‚ โ”‚ CRUD + File I/O โ”‚ +โ”‚ WorkspaceID โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ–บโ”‚ Node binding โ”‚ +โ”‚ } โ”‚ โ”‚ fs.FS interface โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ผ + tai.Client pool +``` + +## Core Types + +```go +type Workspace struct { + ID string + Name string + Owner string + Node string // Tai node this workspace is pinned to + Labels map[string]string + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateOptions struct { + ID string // explicit ID; empty = auto-generate (ws-) + Name string + Owner string + Node string // target Tai node (required) + Labels map[string]string +} + +type ListOptions struct { + Owner string + Node string +} + +type UpdateOptions struct { + Name *string // nil = no change + Labels map[string]string // nil = no change; non-nil replaces all labels +} + +type NodeInfo struct { + Name string + Addr string + Online bool +} + +type DirEntry struct { + Name string + IsDir bool + Size int64 +} +``` + +## Manager API + +```go +type Manager struct { + pools map[string]*tai.Client + mu sync.RWMutex +} + +func NewManager(pools map[string]*tai.Client) *Manager + +// --- CRUD --- +func (m *Manager) Create(ctx, opts CreateOptions) (*Workspace, error) +func (m *Manager) Get(ctx, id string) (*Workspace, error) +func (m *Manager) List(ctx, opts ListOptions) ([]*Workspace, error) +func (m *Manager) Update(ctx, id string, opts UpdateOptions) (*Workspace, error) +func (m *Manager) Delete(ctx, id string, force bool) error + +// --- File I/O --- +func (m *Manager) ReadFile(ctx, id string, path string) ([]byte, error) +func (m *Manager) WriteFile(ctx, id string, path string, data []byte, perm os.FileMode) error +func (m *Manager) ListDir(ctx, id string, path string) ([]DirEntry, error) +func (m *Manager) Remove(ctx, id string, path string) error +func (m *Manager) FS(ctx, id string) (workspace.FS, error) + +// --- Node management --- +func (m *Manager) Nodes() []NodeInfo +func (m *Manager) AddPool(name string, client *tai.Client) +func (m *Manager) RemovePool(name string) + +// --- Sandbox integration --- +func (m *Manager) NodeForWorkspace(ctx, id string) (string, error) +func (m *Manager) MountPath(ctx, id string) (string, error) +``` + +## Metadata Storage + +Workspace metadata is stored as `.workspace.json` inside the workspace's root directory on the Tai node: + +``` +/ +โ”œโ”€โ”€ ws-abc123/ +โ”‚ โ”œโ”€โ”€ .workspace.json โ† metadata (ID, Name, Owner, Node, Labels, timestamps) +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ go.mod +โ”‚ โ””โ”€โ”€ ... +โ”œโ”€โ”€ ws-def456/ +โ”‚ โ””โ”€โ”€ ... +``` + +This approach collocates metadata with data โ€” no external database required. `List()` scans top-level directories and reads each `.workspace.json`. `Get()` scans all nodes until the workspace is found. + +## Errors + +```go +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") +) +``` + +## Package Structure + +``` +workspace/ +โ”œโ”€โ”€ workspace.go // types, metadata marshal/unmarshal +โ”œโ”€โ”€ manager.go // Manager: CRUD, file I/O, node management +โ”œโ”€โ”€ errors.go // sentinel errors +โ”œโ”€โ”€ jsapi/ // (Phase 2) V8 JSAPI workspace.* namespace +โ”‚ โ”œโ”€โ”€ jsapi.go // RegisterObject("workspace"), Create/Get/List/Delete +โ”‚ โ””โ”€โ”€ fs.go // WorkspaceFS JS object: ReadFile/WriteFile/ReadDir/Stat/MkdirAll/Remove/RemoveAll/Rename +โ”œโ”€โ”€ testutils_test.go // shared test helpers +โ”œโ”€โ”€ workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes) +โ”œโ”€โ”€ fileio_test.go // File I/O + fs.FS tests +โ”œโ”€โ”€ bench_test.go // Performance benchmarks +โ””โ”€โ”€ DESIGN.md // detailed design document +``` + +--- + +# Testing + +## Test Environment + +Three pool modes configured via environment variables: + +```bash +# Local โ€” direct Docker daemon (always available) +SANDBOX_TEST_LOCAL_ADDR=local + +# Remote โ€” via Tai container (Docker backend) +SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1:9100 + +# K8s โ€” via Tai container (K8s backend) +TAI_TEST_K8S_HOST= +TAI_TEST_KUBECONFIG= +TAI_TEST_K8S_PORT=6443 +TAI_TEST_K8S_NAMESPACE=default + +# Test image +SANDBOX_TEST_IMAGE=yaoapp/sandbox-v2-test:latest +``` + +Tests skip unavailable modes via `t.Skip`. Both sandbox/v2 and workspace tests iterate over all available pools. + +## Test Coverage + +### sandbox/v2 + +| File | Coverage | +|------|----------| +| `sandbox_test.go` | `Init()`, `M()`, singleton behavior | +| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool management, limits | +| `manager_lifecycle_test.go` | Start (container discovery), Cleanup, idle tracking, Heartbeat | +| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), lifecycle | +| `box_attach_test.go` | Attach WS, Attach SSE, VNC URL, VNC Connect | +| `box_workspace_test.go` | Workspace file I/O through Box, workspace mount integration | +| `box_image_test.go` | ImageExists, PullImage (with progress), EnsureImage, K8s no-op | +| `grpc_test.go` | Token creation/revocation, env var building | +| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | + +### workspace + +| File | Coverage | +|------|----------| +| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (filter owner/node), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool, RemovePool, MountPath | +| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove) | +| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | + +## CI Integration + +Consolidated into two CI jobs: + +| Job | Contents | +|-----|----------| +| `SandboxV2Test` | Image pre-pull โ†’ tai-test โ†’ sandbox/v2 (local+remote+k8s) โ†’ workspace (local+remote) | +| `BenchmarkSandboxV2` | Performance tests for sandbox/v2 + workspace (parallel with SandboxV2Test) | + +## Benchmark Results (Reference) + +| Benchmark | Local | Remote | K8s | +|-----------|-------|--------|-----| +| ContainerLifecycle | ~300ms | ~200ms | ~10s | +| Create | ~100ms | ~80ms | ~8s | +| Exec | ~30ms | ~50ms | ~150ms | +| Remove | ~180ms | ~120ms | ~220ms | +| Info | ~5ms | ~10ms | ~30ms | +| StopStart | ~2.2s | ~2.2s | N/A (skip) | + +K8s `StopStart` is skipped because K8s `Stop` deletes the Pod; a subsequent `Start` cannot restart a deleted Pod. + +Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits the full timeout before SIGKILL unless PID 1 exits on SIGTERM first. + +--- + +# Migration Plan + +## Phase 1: Core (DONE) + +- tai SDK: Sandbox, ExecStream, Image, Proxy.Connect, Labels, User +- sandbox/v2: Manager, Box, all CRUD + Exec + Stream + Attach + Workspace + VNC + Proxy + Image +- workspace: Manager, CRUD, file I/O, node binding, sandbox integration +- gRPC: Heartbeat RPC (proto + handler) +- Tests: unit + integration + benchmarks +- CI: consolidated SandboxV2Test + BenchmarkSandboxV2 + +## Phase 2: JSAPI + OAuth + Auth (PENDING) + +### Prerequisites + +| Task | Detail | +|------|--------| +| Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke | + +### JSAPI Design + +All JSAPI methods are static โ€” no constructors, no Go objects in V8, no bridge/Release. +JS objects only hold string IDs, delegate everything to Go singletons (`sandbox.M()`, `workspace.M()`). + +#### sandbox namespace (`RegisterObject("sandbox")`) + +Static methods: + +| JS | Go | Returns | +|----|-----|---------| +| `sandbox.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `Box` | +| `sandbox.Create(opts)` (opts.id set) | `Manager.GetOrCreate(ctx, CreateOptions)` | `Box` | +| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` | +| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)` โ†’ `Box.Info()` | `BoxInfo[]` | +| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` | + +`sandbox.Create(options)` โ€” JS options โ†’ Go `CreateOptions`: + +``` +{ + id: string โ†’ CreateOptions.ID // optional; triggers GetOrCreate + owner: string โ†’ CreateOptions.Owner // required + pool: string โ†’ CreateOptions.Pool // default: first pool + image: string โ†’ CreateOptions.Image // required + workdir: string โ†’ CreateOptions.WorkDir + user: string โ†’ CreateOptions.User // e.g. "1000:1000" + env: object โ†’ CreateOptions.Env // map[string]string + memory: number โ†’ CreateOptions.Memory // bytes (int64) + cpus: number โ†’ CreateOptions.CPUs // float64 + vnc: boolean โ†’ CreateOptions.VNC + ports: array โ†’ CreateOptions.Ports // [{container, host, host_ip, protocol}] โ†’ []PortMapping + policy: string โ†’ CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent" + idle_timeout: number โ†’ CreateOptions.IdleTimeout // ms โ†’ time.Duration + stop_timeout: number โ†’ CreateOptions.StopTimeout // ms โ†’ time.Duration + workspace_id: string โ†’ CreateOptions.WorkspaceID + mount_mode: string โ†’ CreateOptions.MountMode // "rw"|"ro" + mount_path: string โ†’ CreateOptions.MountPath + labels: object โ†’ CreateOptions.Labels // map[string]string +} +``` + +`sandbox.List(filter?)` โ€” JS filter โ†’ Go `ListOptions`: + +``` +{ + owner: string โ†’ ListOptions.Owner // empty = all + pool: string โ†’ ListOptions.Pool // empty = all + labels: object โ†’ ListOptions.Labels +} +``` + +Returns `BoxInfo[]` โ€” each element: + +``` +{ + id: string โ† BoxInfo.ID + container_id: string โ† BoxInfo.ContainerID + pool: string โ† BoxInfo.Pool + owner: string โ† BoxInfo.Owner + status: string โ† BoxInfo.Status + image: string โ† BoxInfo.Image + vnc: boolean โ† BoxInfo.VNC + policy: string โ† BoxInfo.Policy + labels: object โ† BoxInfo.Labels + created_at: string โ† BoxInfo.CreatedAt (ISO 8601) + last_active: string โ† BoxInfo.LastActive (ISO 8601) + process_count: number โ† BoxInfo.ProcessCount +} +``` + +#### Box object + +Read-only properties: + +| JS | Go | +|----|----| +| `box.id` | `Box.ID()` | +| `box.owner` | `Box.Owner()` | +| `box.pool` | `Box.Pool()` | + +Methods: + +| JS | Go | Returns | +|----|-----|---------| +| `box.Exec(cmd, opts?)` | `Box.Exec(ctx, cmd, ...ExecOption)` | `ExecResult` | +| `box.Stream(cmd, opts?)` | `Box.Stream(ctx, cmd, ...ExecOption)` | `ExecStream` | +| `box.Attach(port, opts?)` | `Box.Attach(ctx, port, ...AttachOption)` | `ServiceConn` | +| `box.VNC()` | `Box.VNC(ctx)` | `string` | +| `box.Proxy(port, path?)` | `Box.Proxy(ctx, port, path)` | `string` | +| `box.Workspace()` | `Box.WorkspaceID()` โ†’ `NewFSObject` | `WorkspaceFS` | +| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` | +| `box.Start()` | `Box.Start(ctx)` | `void` | +| `box.Stop()` | `Box.Stop(ctx)` | `void` | +| `box.Remove()` | `Box.Remove(ctx)` | `void` | + +`box.Exec(cmd, options?)`: + +``` +cmd: string[] โ†’ cmd []string +options: { + workdir: string, โ†’ WithWorkDir(dir) + env: object, โ†’ WithEnv(map[string]string) + timeout: number โ†’ WithTimeout(ms โ†’ time.Duration) +} +returns: { + exit_code: number, โ† ExecResult.ExitCode + stdout: string, โ† ExecResult.Stdout + stderr: string โ† ExecResult.Stderr +} +``` + +`box.Stream(cmd, options?)`: + +``` +options: same as Exec +returns: { + stdout: ReadableStream, โ† ExecStream.Stdout + stderr: ReadableStream, โ† ExecStream.Stderr + stdin: WritableStream, โ† ExecStream.Stdin + wait: function() โ†’ number, โ† ExecStream.Wait() (int, error) + cancel: function() โ†’ void โ† ExecStream.Cancel() +} +``` + +`box.Attach(port, options?)`: + +``` +port: number โ†’ port int +options: { + protocol: "ws"|"sse", โ†’ WithProtocol(protocol) + path: string, โ†’ WithPath(path) + headers: object โ†’ WithHeaders(map[string]string) +} +returns: { + url: string, โ† ServiceConn.URL + read: function() โ†’ Uint8Array, โ† ServiceConn.Read() + write: function(data) โ†’ void, โ† ServiceConn.Write(data) + events: AsyncIterable, โ† ServiceConn.Events + close: function() โ†’ void โ† ServiceConn.Close() +} +``` + +`box.Info()` returns same structure as `BoxInfo[]` element above. + +#### workspace namespace (`RegisterObject("workspace")`) + +Static methods: + +| JS | Go | Returns | +|----|-----|---------| +| `workspace.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `WorkspaceFS` | +| `workspace.Get(id)` | `Manager.Get(ctx, id)` | `WorkspaceFS \| null` | +| `workspace.List(filter?)` | `Manager.List(ctx, ListOptions)` | `WorkspaceInfo[]` | +| `workspace.Delete(id)` | `Manager.Delete(ctx, id, false)` | `void` | + +`workspace.Create(options)` โ€” JS options โ†’ Go `CreateOptions`: + +``` +{ + id: string โ†’ CreateOptions.ID // optional; auto-generated if empty + name: string โ†’ CreateOptions.Name // required + owner: string โ†’ CreateOptions.Owner // required + node: string โ†’ CreateOptions.Node // required + labels: object โ†’ CreateOptions.Labels // map[string]string +} +``` + +`workspace.List(filter?)` โ€” JS filter โ†’ Go `ListOptions`: + +``` +{ + owner: string โ†’ ListOptions.Owner // empty = all + node: string โ†’ ListOptions.Node // empty = all +} +``` + +Returns `WorkspaceInfo[]` โ€” each element: + +``` +{ + id: string โ† Workspace.ID + name: string โ† Workspace.Name + owner: string โ† Workspace.Owner + node: string โ† Workspace.Node + labels: object โ† Workspace.Labels + created_at: string โ† Workspace.CreatedAt (ISO 8601) + updated_at: string โ† Workspace.UpdatedAt (ISO 8601) +} +``` + +#### WorkspaceFS object + +Read-only properties: + +| JS | Go | +|----|----| +| `ws.id` | workspace ID | +| `ws.name` | `Workspace.Name` | +| `ws.node` | `Workspace.Node` | + +Methods (1:1 to Go `taiworkspace.FS` + `Manager` shortcuts): + +| JS | Go | Returns | +|----|-----|---------| +| `ws.ReadFile(path)` | `FS.ReadFile(name)` / `Manager.ReadFile(ctx, id, path)` | `string` | +| `ws.WriteFile(path, data, perm?)` | `FS.WriteFile(name, data, perm)` / `Manager.WriteFile(ctx, id, path, data, perm)` | `void` | +| `ws.ReadDir(path?)` | `FS.ReadDir(name)` / `Manager.ListDir(ctx, id, path)` | `DirEntry[]` | +| `ws.Stat(path)` | `FS.Stat(name)` | `FileInfo` | +| `ws.MkdirAll(path, perm?)` | `FS.MkdirAll(name, perm)` | `void` | +| `ws.Remove(path)` | `FS.Remove(name)` / `Manager.Remove(ctx, id, path)` | `void` | +| `ws.RemoveAll(path)` | `FS.RemoveAll(name)` | `void` | +| `ws.Rename(from, to)` | `FS.Rename(old, new)` | `void` | + +Planned (not yet implemented): + +| JS | Go | Returns | Note | +|----|-----|---------|------| +| `ws.ReadFileBase64(path)` | `FS.ReadFile` โ†’ `base64.StdEncoding.EncodeToString` | `string` | Avoids V8โ†”Go binary bridge overhead for images, archives, etc. | +| `ws.WriteFileBase64(path, b64, perm?)` | `base64.StdEncoding.DecodeString` โ†’ `FS.WriteFile` | `void` | Same โ€” base64 string transfer is far more efficient than Uint8Array across the bridge | +| `ws.CopyFromHost(hostPath, destPath?)` | Host `os.Read` โ†’ `FS.WriteFile` / `FS.MkdirAll` per entry | `void` | Copy file/dir from Yao host into workspace; `destPath` defaults to basename | +| `ws.CopyFromHostArchive(hostPath, destPath?)` | Zip on host โ†’ Tai Volume upload โ†’ Tai-side unarchive | `void` | For large directory trees; requires Tai server-side unarchive support | + +Return types: + +``` +DirEntry: { name: string, is_dir: boolean, size: number } +FileInfo: { name: string, size: number, is_dir: boolean, mod_time: string (ISO 8601) } +``` + +### Auth + +JSAPI does not enforce permissions internally. The Go Manager methods execute operations directly without owner/admin checks. + +Developers retrieve the current caller identity via the gou global `Authorized()` function (registered by `gou/runtime/v8/functions/authorized`, reads from `bridge.Share.Authorized` / `__yao_data.AUTHORIZED`) and implement permission logic in their JS scripts. + +`Authorized()` returns `map[string]interface{}` (or null if not set). The exact fields depend on what the caller sets via `Context.WithAuthorized()`. There is no fixed schema โ€” typical fields include `user_id`, `team_id`, `scope`, etc. + +```javascript +const auth = Authorized() // gou global โ€” returns caller info or null +const box = sandbox.Get(id) +// Developer decides permission logic โ€” fields depend on application's auth setup +if (box.owner !== auth.user_id) { + throw new Error("permission denied") +} +``` + +Permission control is the responsibility of the caller (JS scripts, Agent hooks, API middleware, etc.). + +### Implementation Tasks + +| Task | Detail | +|------|--------| +| `sandbox/v2/jsapi/` | `RegisterObject("sandbox")` with Create/Get/List/Delete + Box object | +| `workspace/jsapi/` | `RegisterObject("workspace")` with Create/Get/List/Delete + FS object | +| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` | + +## Phase 3: Agent Integration (PENDING) + +| Task | Detail | +|------|--------| +| Agent creates Box via `sandbox.M().GetOrCreate()` | Replace `infraSandbox.Manager` | +| Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads | +| Agent uses `Box.Exec()` for commands | Replace Docker exec | +| Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy | + +## Phase 4: Cutover (PENDING) + +| Task | Detail | +|------|--------| +| Move `sandbox/v2` โ†’ `sandbox` | Rename package | +| Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | +| Update `cmd/start.go` | Use new init path | +| `sandbox/process.go` | Register `sandbox.*` process namespace (post-cutover) | +| `workspace/process.go` | Register `workspace.*` process namespace (post-cutover) | + +## V1 vs V2 Comparison + +| Aspect | V1 (current) | V2 (this design) | +|--------|-------------|-------------------| +| **Positioning** | Agent's Claude executor | Yao infrastructure module | +| **Runtime** | Direct Docker SDK | tai.Client pool (Docker/K8s/Remote) | +| **Execution** | Exec + Stream | Exec + Stream + Attach (WS/SSE) | +| **File I/O** | bind mount + Docker Copy | `workspace.FS` (fs.FS compatible) | +| **IPC** | Unix socket + yao-bridge | gRPC (yao-grpc) | +| **Idle detection** | External calls only | Dual: external calls + container heartbeat | +| **Lifecycle** | Chat session only | Policy-based (oneshot/session/longrunning/persistent) | +| **Pool** | Single Docker daemon | Multi-pool with per-pool policies | +| **Agent coupling** | Tightly coupled | Zero dependency | +| **Workspace** | None | Persistent, node-bound, decoupled from containers | +| **Image management** | None | EnsureImage + Pull with progress | +| **K8s** | Not supported | Supported via tai.Client | +| **Multi-node** | Local only | Local + Remote via Tai | diff --git a/sandbox/v2/IMPL.md b/sandbox/v2/IMPL.md new file mode 100644 index 00000000..4514345d --- /dev/null +++ b/sandbox/v2/IMPL.md @@ -0,0 +1,263 @@ +# Sandbox V2 โ€” Implementation Status + +Reference: [DESIGN.md](./DESIGN.md) + +--- + +## Phase 1: Core Module โ€” DONE + +### tai SDK Prerequisites โ€” DONE + +| Step | Package | What | Status | +|------|---------|------|--------| +| 0 | `tai/sandbox` | Labels, User in CreateOptions + ContainerInfo | DONE | +| 1 | `tai/sandbox` | ExecStream (Docker + K8s) | DONE | +| 2 | `tai/proxy` | Connect (WS/SSE, Local + Remote) | DONE | +| 3 | `tai/sandbox` | Image interface (Exists, Pull, Remove, List) | DONE | +| 4 | `tai/tai.go` | Client: Sandbox(), Image(), Proxy(), VNC(), Volume(), Workspace() | DONE | +| 5 | `yao/grpc` | Heartbeat RPC (proto + handler) | DONE | + +### sandbox/v2 Core โ€” DONE + +| File | What | Status | +|------|------|--------| +| `sandbox.go` | `Init()`, `M()`, global singleton | DONE | +| `manager.go` | Manager: Create/Get/GetOrCreate/List/Remove/Cleanup/Close, Start (container recovery), AddPool/RemovePool/Pools, Heartbeat, SetGRPCPort, SetWorkspaceManager, ImageExists/PullImage/EnsureImage | DONE | +| `box.go` | Box: Exec, Stream, Attach, Workspace, VNC, Proxy, Start/Stop/Remove, Info, touch/lastActiveTime/idleTimeout/maxLifetime/stopTimeout | DONE | +| `types.go` | LifecyclePolicy (OneShot/Session/LongRunning/Persistent), Pool, PoolInfo, PortMapping, CreateOptions (with WorkspaceID/MountMode/MountPath), ListOptions, ExecOption/ExecResult/ExecStream, AttachOption/ServiceConn, ImagePullOptions/RegistryAuth, BoxInfo, DefaultStopTimeout | DONE | +| `config.go` | Config struct | DONE | +| `errors.go` | ErrNotAvailable, ErrNotFound, ErrLimitExceeded, ErrPoolNotFound, ErrPoolInUse | DONE | +| `grpc.go` | CreateContainerTokens, RevokeContainerTokens, BuildGRPCEnv | DONE | + +### workspace Module โ€” DONE + +| File | What | Status | +|------|------|--------| +| `workspace.go` | Workspace struct, CreateOptions, ListOptions, UpdateOptions, NodeInfo, MountMode, metadata marshal/unmarshal | DONE | +| `manager.go` | Manager: Create/Get/List/Update/Delete, ReadFile/WriteFile/ListDir/Remove/FS, Nodes/AddPool/RemovePool, NodeForWorkspace/MountPath | DONE | +| `errors.go` | ErrNotFound, ErrNodeMissing, ErrNodeOffline, ErrHasMounts | DONE | + +### Tests โ€” DONE + +| File | Coverage | Status | +|------|----------|--------| +| **sandbox/v2** | | | +| `sandbox_test.go` | Init, M, singleton | DONE | +| `manager_test.go` | Create, Get, GetOrCreate, List, Remove, pool limits (MaxTotal, MaxPerUser), multi-pool | DONE | +| `manager_lifecycle_test.go` | Start (recovery), Cleanup, idle tracking, Heartbeat | DONE | +| `box_test.go` | Exec, Info, Workspace (ReadFile/WriteFile), status | DONE | +| `box_attach_test.go` | Attach WS echo, Attach SSE events, VNC URL, VNC Connect (RFB handshake) | DONE | +| `box_workspace_test.go` | Workspace mount, file I/O through Box, invalid ID | DONE | +| `box_image_test.go` | ImageExists (Docker+K8s), PullImage (progress+K8s no-op), EnsureImage, bad ref | DONE | +| `grpc_test.go` | Token creation/revocation, env var building (local vs remote) | DONE | +| `bench_test.go` | ContainerLifecycle, Create, Exec, ExecHeavy, Remove, Info, StopStart, WorkspaceReadWrite | DONE | +| `testutils_test.go` | testPools (local/remote/k8s), setupManager, createTestBox, ensureTestImage | DONE | +| `export_test.go` | ResetForTest | DONE | +| **workspace** | | | +| `workspace_test.go` | Create (auto/explicit ID, labels, invalid node), Get, List (owner/node filter), Update (name/labels), Delete, Nodes, NodeForWorkspace, AddPool/RemovePool, MountPath | DONE | +| `fileio_test.go` | ReadWriteFile, nested paths, ListDir, Remove, fs.FS (ReadFile, WriteFile, MkdirAll, Rename, WalkDir, Remove, NotFound) | DONE | +| `bench_test.go` | WriteFile, ReadFile, ReadWriteCycle, WriteLargeFile, ListDir, FSWalkDir, CreateDelete | DONE | +| `testutils_test.go` | testPools, setupManagerForPool, clientForPool, localClient, setupManagerMultiNode, createWorkspace | DONE | + +### CI โ€” DONE + +| Job | Contents | Status | +|-----|----------|--------| +| `SandboxV2Test` | Consolidated: image pre-pull โ†’ tai-test โ†’ sandbox/v2 (local+remote+k8s) โ†’ workspace (local+remote) | DONE | +| `BenchmarkSandboxV2` | Parallel: performance tests for sandbox/v2 + workspace | DONE | +| `GRPCTest` | Independent: gRPC tests (unchanged) | DONE | + +### Performance Optimizations โ€” DONE + +| Optimization | Before | After | Impact | +|-------------|--------|-------|--------| +| Remove redundant Stop in Manager.Remove() | 2.14s | 177ms | 12x faster Docker remove | +| Container CMD trap SIGTERM | 2s+ stop | near-instant | Graceful shutdown on Stop | +| K8s Start: respect ctx deadline | 30s hardcoded | ctx-aware + 60s default | Proper timeout propagation | +| K8s Pod spec: Args vs Command | CMD overridden | ENTRYPOINT preserved | Correct container behavior | + +--- + +## Phase 2: JSAPI + OAuth โ€” PENDING + +| Task | Package | Detail | +|------|---------|--------| +| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` + `Workspace()` constructors (registered in gou runtime) | +| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls | +| `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence | +| Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler โ†’ `sandbox.M().Heartbeat()` | + +### JSAPI (planned) + +```javascript +// Sandbox +var sb = Sandbox("my-workspace", { + image: "yaoapp/workspace:latest", + owner: "user-123" +}) +sb.Exec(["go", "build", "./..."]) +sb.ReadFile("src/main.go") +sb.WriteFile("src/main.go", "package main\n...") +sb.Stream(["npm", "run", "dev"], function(chunk) { ... }) +var conn = sb.Attach(3000, { protocol: "ws", path: "/ws" }) +sb.Info() +sb.Stop() +sb.Start() +sb.Remove() + +// Workspace +var ws = Workspace("my-workspace") +ws.ReadFile("src/main.go") +ws.WriteFile("src/main.go", "package main\n...") +ws.ListDir("src/") +ws.Remove("tmp.txt") +``` + +--- + +## Phase 3: Agent Integration โ€” PENDING + +| Task | Detail | +|------|--------| +| Agent creates Box via `sandbox.M().GetOrCreate()` | Replace `infraSandbox.Manager` | +| Agent uses `Box.Workspace()` for file I/O | Replace Docker Copy/bind mount reads | +| Agent uses `Box.Exec()` for commands | Replace Docker exec | +| Agent uses `Box.VNC()` / `Box.Proxy()` | Replace vncproxy | +| Agent injects `Box` as `SandboxExecutor` | `ctx.sandbox` JSAPI unchanged for hooks | + +--- + +## Phase 4: Cutover โ€” PENDING + +| Task | Detail | +|------|--------| +| Move `sandbox/v2` โ†’ `sandbox` | Rename package | +| Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | +| Delete `DESIGN-REMOTE.md` | Superseded by tai.Client | +| Update `cmd/start.go` | Use new init path | +| `sandbox/process.go` | Register `sandbox.*` process namespace (post-cutover) | +| `workspace/process.go` | Register `workspace.*` process namespace (post-cutover) | + +--- + +## Implementation Details + +### Container CMD + +All V2 containers use a SIGTERM-aware sleep as PID 1: + +```bash +sh -c "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done" +``` + +This ensures: +- Container stays alive indefinitely (no hardcoded `sleep infinity`) +- Exits immediately on SIGTERM (no 2s wait) +- Works on both Docker and K8s + +### Container Labels + +Manager injects these labels at creation time: + +``` +managed-by=yao-sandbox +sandbox-id= +sandbox-owner= +sandbox-pool= +sandbox-policy= +workspace-id= (if WorkspaceID set) +``` + +Used by `Manager.Start()` to discover and recover existing containers after restart. + +### Workspace Bind Mount + +When `CreateOptions.WorkspaceID` is set: + +``` +1. NodeForWorkspace(wsID) โ†’ node name +2. Force pool = node name +3. MountPath(wsID) โ†’ hostDir +4. Bind: hostDir:/workspace:rw +``` + +### Multi-Mode Testing + +`testPools()` returns all available pool configurations: + +```go +func testPools() []poolConfig { + pools := []poolConfig{{Name: "local", Addr: testLocalAddr()}} + 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 != "" { + // ... K8s pool with kubeconfig, namespace, ports + pools = append(pools, poolConfig{Name: "k8s", ...}) + } + return pools +} +``` + +Every test iterates over all available pools: + +```go +func TestSomething(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + // test logic + }) + } +} +``` + +### Benchmark Helpers + +```go +func setupManagerForBench(b *testing.B, pc poolConfig) *sandbox.Manager +func ensureTestImageBench(b *testing.B, m *sandbox.Manager, pool string) +func createBoxForBench(b *testing.B, m *sandbox.Manager) *sandbox.Box +``` + +K8s-specific behavior: +- `BenchmarkStopStart`: skipped (K8s Stop deletes Pod) +- Create/Lifecycle benchmarks: 120s timeout for K8s Pod scheduling + +--- + +## File Inventory + +### sandbox/v2 (7 source + 10 test = 17 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `sandbox.go` | ~25 | Global singleton | +| `manager.go` | ~620 | Manager implementation | +| `box.go` | ~230 | Box implementation | +| `types.go` | ~170 | Type definitions | +| `config.go` | ~5 | Config struct | +| `errors.go` | ~10 | Error definitions | +| `grpc.go` | ~55 | Token/env injection | +| `testutils_test.go` | ~130 | Test helpers | +| `sandbox_test.go` | ~30 | Singleton tests | +| `manager_test.go` | ~250 | CRUD tests | +| `manager_lifecycle_test.go` | ~120 | Lifecycle tests | +| `box_test.go` | ~200 | Box tests | +| `box_attach_test.go` | ~260 | Attach/VNC tests | +| `box_workspace_test.go` | ~285 | Workspace tests | +| `box_image_test.go` | ~120 | Image tests | +| `grpc_test.go` | ~80 | Token tests | +| `bench_test.go` | ~230 | Benchmarks | + +### workspace (3 source + 4 test = 7 files) + +| File | Lines | Purpose | +|------|-------|---------| +| `workspace.go` | ~80 | Types + metadata | +| `manager.go` | ~320 | Manager implementation | +| `errors.go` | ~10 | Error definitions | +| `testutils_test.go` | ~90 | Test helpers | +| `workspace_test.go` | ~325 | CRUD tests | +| `fileio_test.go` | ~235 | File I/O tests | +| `bench_test.go` | ~150 | Benchmarks | diff --git a/sandbox/v2/Makefile b/sandbox/v2/Makefile new file mode 100644 index 00000000..e0bb9ee5 --- /dev/null +++ b/sandbox/v2/Makefile @@ -0,0 +1,108 @@ +GO ?= go +GOFILES := $(shell find . -name "*.go" -not -path "./docker/*") +PACKAGES := $(shell $(GO) list ./...) +TEST_IMAGE ?= yaoapp/sandbox-v2-test:latest +TEST_TIMEOUT ?= 600s + +# --------------------------------------------------------------------------- +# Local test (Docker only) +# --------------------------------------------------------------------------- +.PHONY: test-local +test-local: + @echo "=== Sandbox V2: local mode ===" + SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \ + $(GO) test $(PACKAGES) -count=1 -v -timeout $(TEST_TIMEOUT) -run '/local' + +# --------------------------------------------------------------------------- +# Remote test (requires Tai server) +# --------------------------------------------------------------------------- +.PHONY: test-remote +test-remote: + @if [ -z "$(SANDBOX_TEST_REMOTE_ADDR)" ]; then \ + echo "SANDBOX_TEST_REMOTE_ADDR not set, skipping remote tests"; \ + exit 0; \ + fi + @echo "=== Sandbox V2: remote mode ($(SANDBOX_TEST_REMOTE_ADDR)) ===" + SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \ + $(GO) test $(PACKAGES) -count=1 -v -timeout $(TEST_TIMEOUT) -run '/remote' + +# --------------------------------------------------------------------------- +# Dual-mode test (local + remote when configured) +# --------------------------------------------------------------------------- +.PHONY: test +test: + @echo "" + @echo "=============================================" + @echo "Sandbox V2 Tests (dual-mode)" + @echo "=============================================" + @echo "Image: $(TEST_IMAGE)" + @echo "Remote: $${SANDBOX_TEST_REMOTE_ADDR:-}" + @echo "" + SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \ + $(GO) test $(PACKAGES) -count=1 -v -timeout $(TEST_TIMEOUT) + @echo "" + @echo "=============================================" + @echo "Sandbox V2 Tests passed" + @echo "=============================================" + +# --------------------------------------------------------------------------- +# CI test (with coverage, used by Makefile at repo root) +# --------------------------------------------------------------------------- +.PHONY: test-ci +test-ci: + @echo "" + @echo "=============================================" + @echo "Sandbox V2 CI Tests" + @echo "=============================================" + echo "mode: count" > coverage.out + @for d in $(PACKAGES); do \ + SANDBOX_TEST_IMAGE=$(TEST_IMAGE) \ + $(GO) test -v -count=1 -timeout $(TEST_TIMEOUT) \ + -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; \ + fi; \ + if [ -f profile.out ]; then \ + cat profile.out | grep -v "mode:" >> coverage.out; \ + rm profile.out; \ + fi; \ + done + @echo "" + @echo "=============================================" + @echo "Sandbox V2 CI Tests passed" + @echo "=============================================" + +# --------------------------------------------------------------------------- +# Docker images +# --------------------------------------------------------------------------- +.PHONY: docker-build +docker-build: + ./docker/build.sh build + +.PHONY: docker-push +docker-push: + ./docker/build.sh push + +# --------------------------------------------------------------------------- +# fmt / vet +# --------------------------------------------------------------------------- +.PHONY: fmt +fmt: + gofmt -s -w $(GOFILES) + +.PHONY: vet +vet: + $(GO) vet $(PACKAGES) diff --git a/sandbox/v2/TEST.md b/sandbox/v2/TEST.md new file mode 100644 index 00000000..c6c7cd56 --- /dev/null +++ b/sandbox/v2/TEST.md @@ -0,0 +1,621 @@ +# Sandbox V2 โ€” Test Specification + +Design: [DESIGN.md](./DESIGN.md) | Implementation: [IMPL.md](./IMPL.md) + +## Principles + +- **Black-box testing**: all `*_test.go` files use `package sandbox_test` โ€” tests only access exported API +- **Real containers**: tests create real Docker containers via tai SDK, no mocking +- **Skip when unavailable**: `skipIfNoDocker(t)` / `skipIfNoTai(t)` โ€” CI has Docker and Tai; local dev may not +- **Tests follow implementation**: `*_test.go` lives next to the code it tests +- **Coverage > 80%**: per file and overall + +## Prerequisites + +```bash +source $YAO_SOURCE_ROOT/env.local.sh +``` + +### Docker (required for all container tests) + +Docker daemon must be running. Tests connect via default socket. + +### Tai (required for remote-mode tests only) + +```bash +docker run -d --name tai \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 2375:2375 -p 9100:9100 -p 8080:8080 -p 6080:6080 \ + yaoapp/tai:latest +``` + +### Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `YAO_TEST_APPLICATION` | Path to `yao-dev-app` | โ€” (required) | +| `YAO_DB_DRIVER` / `YAO_DB_PRIMARY` | Database connection | โ€” (required) | +| `YAO_JWT_SECRET` / `YAO_DB_AESKEY` | Crypto keys (for OAuth token creation) | โ€” (required) | +| `SANDBOX_TEST_IMAGE` | Container image for tests | `yaoapp/sandbox-v2-test:latest` | +| `SANDBOX_TEST_REMOTE_ADDR` | Tai remote address, e.g. `tai://127.0.0.1` | โ€” (skip remote tests if empty) | +| `TAI_TEST_HOST` | Tai HTTP proxy host | `127.0.0.1` | + +## Directory Structure + +``` +sandbox/v2/ +โ”œโ”€โ”€ sandbox.go +โ”œโ”€โ”€ sandbox_test.go # Init/M singleton tests +โ”œโ”€โ”€ manager.go +โ”œโ”€โ”€ manager_test.go # Create/Get/GetOrCreate/List/Remove, pool management +โ”œโ”€โ”€ manager_lifecycle_test.go # Start recovery, Cleanup, idle tracking, heartbeat +โ”œโ”€โ”€ box.go +โ”œโ”€โ”€ box_test.go # Exec/Stream/Workspace/Proxy/VNC, lifecycle +โ”œโ”€โ”€ box_attach_test.go # Attach WS/SSE (needs service in container) +โ”œโ”€โ”€ config.go +โ”œโ”€โ”€ types.go +โ”œโ”€โ”€ errors.go +โ”œโ”€โ”€ grpc.go +โ”œโ”€โ”€ grpc_test.go # OAuth token creation/revocation, env var building +โ”œโ”€โ”€ testutils_test.go # shared test helpers (unexported, package sandbox_test) +โ””โ”€โ”€ DESIGN.md +``` + +## testutils (internal to sandbox_test) + +Shared helpers in `testutils_test.go` โ€” not a separate package, lives inside `package sandbox_test`. + +```go +// testutils_test.go +package sandbox_test + +// skipIfNoDocker skips the test if Docker is not available. +func skipIfNoDocker(t *testing.T) + +// skipIfNoTai skips the test if SANDBOX_TEST_REMOTE_ADDR is empty. +func skipIfNoTai(t *testing.T) + +// testImage returns SANDBOX_TEST_IMAGE or "yaoapp/sandbox-v2-test:latest". +func testImage() string + +// setupManager initializes sandbox with a local pool, returns cleanup func. +// Calls sandbox.Init + sandbox.M().Start. +func setupManager(t *testing.T) func() + +// setupManagerWithRemote initializes sandbox with local + remote pools. +func setupManagerWithRemote(t *testing.T) func() + +// createTestBox creates a box with defaults and returns it. Registers t.Cleanup for removal. +func createTestBox(t *testing.T, opts ...sandbox.CreateOption) *sandbox.Box +``` + +## How to Write a Test + +### Standard pattern + +```go +// manager_test.go +package sandbox_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestCreate(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + + box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Owner: "test-user", + }) + require.NoError(t, err) + defer box.Remove(context.Background()) + + assert.NotEmpty(t, box.ID()) + assert.Equal(t, "test-user", box.Owner()) +} + +func TestCreate_NoImage(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + + _, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{}) + assert.Error(t, err) // Image is required +} +``` + +### Container execution tests + +```go +// box_test.go +package sandbox_test + +func TestExec(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + result, err := box.Exec(context.Background(), []string{"echo", "hello"}) + require.NoError(t, err) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, "hello\n", result.Stdout) + assert.Empty(t, result.Stderr) +} + +func TestExec_NonZeroExit(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + result, err := box.Exec(context.Background(), []string{"sh", "-c", "exit 42"}) + require.NoError(t, err) + assert.Equal(t, 42, result.ExitCode) +} +``` + +### Streaming tests + +```go +// box_test.go +package sandbox_test + +func TestStream(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + s, err := box.Stream(context.Background(), []string{"sh", "-c", "echo a; sleep 0.1; echo b"}) + require.NoError(t, err) + + out, _ := io.ReadAll(s.Stdout) + code, err := s.Wait() + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Contains(t, string(out), "a\n") + assert.Contains(t, string(out), "b\n") +} + +func TestStream_Cancel(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + s, err := box.Stream(context.Background(), []string{"sleep", "60"}) + require.NoError(t, err) + + s.Cancel() + code, _ := s.Wait() + assert.NotEqual(t, 0, code) // killed +} + +func TestStream_Stdin(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + s, err := box.Stream(context.Background(), []string{"cat"}) + require.NoError(t, err) + + s.Stdin.Write([]byte("hello\n")) + s.Stdin.Close() + + out, _ := io.ReadAll(s.Stdout) + code, _ := s.Wait() + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", string(out)) +} +``` + +### Workspace tests + +```go +// box_test.go +package sandbox_test + +func TestWorkspace_ReadWrite(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + ws := box.Workspace() + err := ws.WriteFile("test.txt", []byte("hello"), 0644) + require.NoError(t, err) + + data, err := fs.ReadFile(ws, "test.txt") + require.NoError(t, err) + assert.Equal(t, "hello", string(data)) +} + +func TestWorkspace_MkdirAll(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + ws := box.Workspace() + err := ws.MkdirAll("a/b/c", 0755) + require.NoError(t, err) + + info, err := fs.Stat(ws, "a/b/c") + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestWorkspace_WalkDir(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + ws := box.Workspace() + ws.MkdirAll("src", 0755) + ws.WriteFile("src/main.go", []byte("package main"), 0644) + ws.WriteFile("src/util.go", []byte("package main"), 0644) + + var files []string + fs.WalkDir(ws, "src", func(path string, d fs.DirEntry, err error) error { + if !d.IsDir() { files = append(files, path) } + return nil + }) + assert.Len(t, files, 2) +} +``` + +### Lifecycle tests + +```go +// manager_lifecycle_test.go +package sandbox_test + +func TestIdleCleanup_Session(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + + box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Policy: sandbox.Session, + IdleTimeout: 2 * time.Second, + }) + require.NoError(t, err) + + // Box exists + _, err = sandbox.M().Get(context.Background(), box.ID()) + assert.NoError(t, err) + + // Wait for idle + cleanup cycle + time.Sleep(4 * time.Second) + sandbox.M().Cleanup(context.Background()) + + // Box should be gone + _, err = sandbox.M().Get(context.Background(), box.ID()) + assert.ErrorIs(t, err, sandbox.ErrNotFound) +} + +func TestStartRecovery(t *testing.T) { + skipIfNoDocker(t) + + // Phase 1: create a box, then shut down Manager + cleanup1 := setupManager(t) + box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Owner: "recovery-test", + }) + require.NoError(t, err) + boxID := box.ID() + cleanup1() // closes Manager, but container stays + + // Phase 2: new Manager, Start should discover the container + cleanup2 := setupManager(t) + defer cleanup2() + + recovered, err := sandbox.M().Get(context.Background(), boxID) + require.NoError(t, err) + assert.Equal(t, boxID, recovered.ID()) + assert.Equal(t, "recovery-test", recovered.Owner()) + + // Clean up + recovered.Remove(context.Background()) +} + +func TestHeartbeat(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + box := createTestBox(t) + + // Simulate heartbeat + err := sandbox.M().Heartbeat(box.ID(), true, 3) + assert.NoError(t, err) + + info, _ := box.Info(context.Background()) + assert.Equal(t, 3, info.ProcessCount) +} + +func TestHeartbeat_NotFound(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + + err := sandbox.M().Heartbeat("nonexistent", true, 1) + assert.ErrorIs(t, err, sandbox.ErrNotFound) +} +``` + +### Pool management tests + +```go +// manager_test.go +package sandbox_test + +func TestPoolLimits_MaxTotal(t *testing.T) { + skipIfNoDocker(t) + + // Init with MaxTotal=1 + err := sandbox.Init(sandbox.Config{ + Pool: []sandbox.Pool{{ + Name: "limited", + Addr: "local", + MaxTotal: 1, + }}, + }) + require.NoError(t, err) + sandbox.M().Start(context.Background()) + defer sandbox.M().Close() + + box1, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + }) + require.NoError(t, err) + defer box1.Remove(context.Background()) + + _, err = sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + }) + assert.ErrorIs(t, err, sandbox.ErrLimitExceeded) +} + +func TestAddPool(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + + err := sandbox.M().AddPool(context.Background(), sandbox.Pool{ + Name: "new-pool", + Addr: "local", + }) + assert.NoError(t, err) + + pools := sandbox.M().Pools() + names := make([]string, len(pools)) + for i, p := range pools { names[i] = p.Name } + assert.Contains(t, names, "new-pool") +} + +func TestRemovePool_InUse(t *testing.T) { + skipIfNoDocker(t) + cleanup := setupManager(t) + defer cleanup() + + box := createTestBox(t) + _ = box + + err := sandbox.M().RemovePool(context.Background(), "local", false) + assert.ErrorIs(t, err, sandbox.ErrPoolInUse) +} +``` + +### Multi-pool tests + +```go +// manager_test.go +package sandbox_test + +func TestMultiPool(t *testing.T) { + skipIfNoDocker(t) + skipIfNoTai(t) + cleanup := setupManagerWithRemote(t) + defer cleanup() + + // Create on local + local, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Pool: "local", + }) + require.NoError(t, err) + defer local.Remove(context.Background()) + + // Create on remote + remote, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + Pool: "remote", + }) + require.NoError(t, err) + defer remote.Remove(context.Background()) + + // Both should exec + r1, _ := local.Exec(context.Background(), []string{"echo", "local"}) + r2, _ := remote.Exec(context.Background(), []string{"echo", "remote"}) + assert.Equal(t, "local\n", r1.Stdout) + assert.Equal(t, "remote\n", r2.Stdout) +} +``` + +### OAuth / gRPC env injection tests + +```go +// grpc_test.go +package sandbox_test + +func TestBuildGRPCEnv_Local(t *testing.T) { + env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "local"}, "sb-001", "tok", "ref") + assert.Equal(t, "sb-001", env["YAO_SANDBOX_ID"]) + assert.Equal(t, "tok", env["YAO_TOKEN"]) + assert.Equal(t, "ref", env["YAO_REFRESH_TOKEN"]) + assert.NotEmpty(t, env["YAO_GRPC_ADDR"]) + assert.Empty(t, env["YAO_GRPC_TAI"]) +} + +func TestBuildGRPCEnv_Remote(t *testing.T) { + env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "tai://gpu.internal"}, "sb-002", "tok", "ref") + assert.Equal(t, "enable", env["YAO_GRPC_TAI"]) + assert.NotEmpty(t, env["YAO_GRPC_UPSTREAM"]) +} + +func TestCreateContainerTokens(t *testing.T) { + // Requires Yao runtime for OAuth + cleanup := setupManager(t) + defer cleanup() + + access, refresh, err := sandbox.CreateContainerTokens("sb-test", "user-1") + require.NoError(t, err) + assert.NotEmpty(t, access) + assert.NotEmpty(t, refresh) +} +``` + +## Required Test Cases + +| File | Required Cases | +|------|---------------| +| `sandbox_test.go` | `Init` succeeds / `M()` panics before Init / double Init is safe | +| `manager_test.go` | Create / Create with explicit ID / Create no image (error) / Get / Get not found / GetOrCreate / List / List with owner filter / Remove / pool limits MaxTotal / pool limits MaxPerUser / AddPool / RemovePool / RemovePool in use / Pools | +| `manager_lifecycle_test.go` | Start recovery from labels / Cleanup Session idle / Cleanup LongRunning stop then remove / Persistent never cleaned / Heartbeat updates / Heartbeat not found / OneShot removed after Exec | +| `box_test.go` | Exec success / Exec non-zero exit / Exec with WorkDir / Exec with Env / Exec with Timeout / Stream read / Stream cancel / Stream stdin / Workspace ReadFile+WriteFile / Workspace MkdirAll / Workspace Remove / Workspace Rename / Workspace WalkDir / VNC (skip if no VNC image) / Proxy URL / Start+Stop+Start / Info | +| `box_attach_test.go` | Attach WS (skip if no WS server image) / Attach SSE (skip if no SSE server image) | +| `grpc_test.go` | BuildGRPCEnv local / BuildGRPCEnv remote / CreateContainerTokens / RevokeContainerTokens | + +## Makefile + +Add to [Makefile](../../Makefile): + +```makefile +TESTFOLDER_SANDBOX_V2 := $(shell $(GO) list ./sandbox/v2/...) + +.PHONY: unit-test-sandbox-v2 +unit-test-sandbox-v2: + echo "mode: count" > coverage.out + for d in $(TESTFOLDER_SANDBOX_V2); do \ + $(GO) test -tags $(TESTTAGS) -v -timeout=10m \ + -covermode=count -coverprofile=profile.out \ + -coverpkg=$$d \ + $$d > tmp.out; \ + cat tmp.out; \ + if grep -q "^--- FAIL" tmp.out; then \ + rm tmp.out; \ + exit 1; \ + elif grep -q "build failed" 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 +``` + +## CI Integration + +Add `sandbox-v2-test` job to `unit-test.yml` and `pr-test.yml`: + +```yaml +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"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Start Tai container + run: | + docker run -d --name tai \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 2375:2375 -p 9100:9100 -p 8080:8080 -p 6080:6080 \ + yaoapp/tai:latest + sleep 3 + + - name: Build V2 test image + run: | + cd sandbox/docker + bash build.sh v2 + + - name: Setup ENV + run: | + echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV + echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV + echo "SANDBOX_TEST_IMAGE=yaoapp/sandbox-v2-test:latest" >> $GITHUB_ENV + echo "SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1" >> $GITHUB_ENV + echo "TAI_TEST_HOST=127.0.0.1" >> $GITHUB_ENV + mkdir -p ${{ github.WORKSPACE }}/../app/db + + - name: Run Sandbox V2 Tests + run: make unit-test-sandbox-v2 + + - name: Codecov Report + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} +``` + +Key decisions: +- SQLite only โ€” sandbox is infrastructure, not data-model dependent +- Tai container provides remote mode โ€” exercises the full proxy path +- `sandbox-v2-test` as default test image โ€” includes `yao-grpc` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services +- CI builds test image from source (Step 4.5) โ€” ensures binary compatibility with latest tai SDK + yao-grpc changes +- Attach tests (WS/SSE) use `sandbox-v2-test` image's built-in test services + +## Coverage + +- Target: >80% per file, >80% overall +- `sandbox.go` (singleton) covered via `sandbox_test.go` +- `manager.go` is the heaviest file โ€” must have dedicated `manager_test.go` + `manager_lifecycle_test.go` +- `box.go` exercises all tai SDK integration points +- `grpc.go` tested with pure unit tests (token generation, env building) + +## Running Tests + +```bash +# All sandbox v2 tests (local Docker only) +make unit-test-sandbox-v2 + +# With remote mode (start Tai first) +SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 make unit-test-sandbox-v2 + +# Single file +go test -v ./sandbox/v2/ -run TestCreate + +# Single test +go test -v ./sandbox/v2/ -run TestExec_NonZeroExit + +# With race detector +go test -race -v ./sandbox/v2/ +``` 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 new file mode 100644 index 00000000..5cb5bfc0 --- /dev/null +++ b/sandbox/v2/box.go @@ -0,0 +1,283 @@ +package sandbox + +import ( + "context" + "io" + "sync/atomic" + "time" + + "github.com/yaoapp/yao/tai/proxy" + taisandbox "github.com/yaoapp/yao/tai/sandbox" + "github.com/yaoapp/yao/tai/workspace" +) + +// Box represents a single sandbox instance. +type Box struct { + id string + containerID string + pool string + owner string + policy LifecyclePolicy + labels map[string]string + lastCall atomic.Int64 + 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 +} + +func (b *Box) ID() string { return b.id } +func (b *Box) Owner() string { return b.owner } +func (b *Box) ContainerID() string { return b.containerID } +func (b *Box) Pool() string { return b.pool } + +// Exec runs a command and waits for it to finish. +func (b *Box) Exec(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecResult, error) { + b.touch() + cfg := &execConfig{} + for _, o := range opts { + o(cfg) + } + + client, err := b.manager.getPool(b.pool) + if err != nil { + return nil, err + } + + result, err := client.Sandbox().Exec(ctx, b.containerID, cmd, taisandbox.ExecOptions{ + WorkDir: cfg.WorkDir, + Env: cfg.Env, + }) + if err != nil { + return nil, err + } + + r := &ExecResult{ + ExitCode: result.ExitCode, + Stdout: result.Stdout, + Stderr: result.Stderr, + } + + if b.policy == OneShot { + b.manager.Remove(ctx, b.id) + } + + return r, nil +} + +// Stream runs a command with real-time streaming I/O. +func (b *Box) Stream(ctx context.Context, cmd []string, opts ...ExecOption) (*ExecStream, error) { + b.touch() + cfg := &execConfig{} + for _, o := range opts { + o(cfg) + } + + client, err := b.manager.getPool(b.pool) + if err != nil { + return nil, err + } + + handle, err := client.Sandbox().ExecStream(ctx, b.containerID, cmd, taisandbox.ExecOptions{ + WorkDir: cfg.WorkDir, + Env: cfg.Env, + }) + if err != nil { + return nil, err + } + + return &ExecStream{ + Stdout: io.NopCloser(handle.Stdout), + Stderr: io.NopCloser(handle.Stderr), + Stdin: handle.Stdin, + Wait: handle.Wait, + Cancel: handle.Cancel, + }, nil +} + +// Attach connects to a service running inside the sandbox on the given container port. +func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*ServiceConn, error) { + b.touch() + cfg := &attachConfig{Protocol: "ws"} + for _, o := range opts { + o(cfg) + } + + client, err := b.manager.getPool(b.pool) + if err != nil { + return nil, err + } + + conn, err := client.Proxy().Connect(ctx, b.containerID, proxy.ConnectOptions{ + Port: port, + Path: cfg.Path, + Protocol: cfg.Protocol, + }) + if err != nil { + return nil, err + } + + sc := &ServiceConn{ + Write: conn.Send, + Events: conn.Messages, + Close: conn.Close, + } + + if cfg.Protocol == "ws" { + ch := conn.Messages + sc.Read = func() ([]byte, error) { + msg, ok := <-ch + if !ok { + return nil, io.EOF + } + return msg, nil + } + } + + return sc, nil +} + +// 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(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() + client, err := b.manager.getPool(b.pool) + if err != nil { + return "", err + } + return client.VNC().URL(ctx, b.containerID) +} + +// Proxy returns the HTTP URL for a service on the given port inside the sandbox. +func (b *Box) Proxy(ctx context.Context, port int, path string) (string, error) { + b.touch() + client, err := b.manager.getPool(b.pool) + if err != nil { + return "", err + } + return client.Proxy().URL(ctx, b.containerID, port, path) +} + +// Start starts a stopped sandbox. +func (b *Box) Start(ctx context.Context) error { + client, err := b.manager.getPool(b.pool) + if err != nil { + return err + } + return client.Sandbox().Start(ctx, b.containerID) +} + +// Stop stops the sandbox without removing it. +func (b *Box) Stop(ctx context.Context) error { + client, err := b.manager.getPool(b.pool) + if err != nil { + return err + } + return client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout()) +} + +// Remove stops and removes the sandbox. +func (b *Box) Remove(ctx context.Context) error { + return b.manager.Remove(ctx, b.id) +} + +// Info returns current sandbox status. +func (b *Box) Info(ctx context.Context) (*BoxInfo, error) { + client, err := b.manager.getPool(b.pool) + if err != nil { + return nil, err + } + + info, err := client.Sandbox().Inspect(ctx, b.containerID) + if err != nil { + return nil, err + } + + return &BoxInfo{ + ID: b.id, + ContainerID: b.containerID, + Pool: b.pool, + Owner: b.owner, + Status: info.Status, + Policy: b.policy, + Labels: b.labels, + Image: info.Image, + CreatedAt: b.createdAt, + LastActive: b.lastActiveTime(), + ProcessCount: int(b.processCount.Load()), + VNC: b.vnc, + }, nil +} + +func (b *Box) touch() { + b.lastCall.Store(time.Now().UnixMilli()) +} + +func (b *Box) lastActiveTime() time.Time { + call := b.lastCall.Load() + hb := b.lastHeartbeat.Load() + ts := call + if hb > ts { + ts = hb + } + return time.UnixMilli(ts) +} + +func (b *Box) idleTimeout() time.Duration { + if b.idleTimeoutD > 0 { + return b.idleTimeoutD + } + pd := b.manager.findPoolDef(b.pool) + if pd != nil { + return pd.IdleTimeout + } + return 0 +} + +func (b *Box) maxLifetime() time.Duration { + pd := b.manager.findPoolDef(b.pool) + if pd != nil { + return pd.MaxLifetime + } + return 0 +} + +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 new file mode 100644 index 00000000..8b4a0228 --- /dev/null +++ b/sandbox/v2/box_attach_test.go @@ -0,0 +1,280 @@ +package sandbox_test + +import ( + "context" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + proxyURL, err := box.Proxy(ctx, port, "/") + if err != nil { + t.Fatalf("Proxy URL: %v", err) + } + + host := proxyURL[len("http://"):] + if i := len(host) - 1; host[i] == '/' { + host = host[:i] + } + for i := 0; i < len(host); i++ { + if host[i] == '/' { + host = host[:i] + break + } + } + + deadline := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-deadline: + t.Fatalf("port %d not ready within %v", port, timeout) + case <-ticker.C: + conn, err := net.DialTimeout("tcp", host, 2*time.Second) + if err != nil { + continue + } + 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 + } + } +} + +func TestAttachWS(t *testing.T) { + skipIfNoDocker(t) + + img := testImage() + if img == "alpine:latest" { + t.Skip("WebSocket test requires sandbox-v2-test image with ws-echo service") + } + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + co.Ports = []sandbox.PortMapping{ + {ContainerPort: 9800, HostPort: 0, Protocol: "tcp"}, + } + }) + + waitForPort(t, box, 9800, 30*time.Second) + + 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 after retries: %v", err) + } + defer conn.Close() + + if err := conn.Write([]byte("ping")); err != nil { + t.Fatalf("Write: %v", err) + } + + msg, err := conn.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(msg) != "ping" { + t.Errorf("echo = %q, want %q", string(msg), "ping") + } + }) + } +} + +func TestAttachSSE(t *testing.T) { + skipIfNoDocker(t) + + img := testImage() + if img == "alpine:latest" { + t.Skip("SSE test requires sandbox-v2-test image with sse-server service") + } + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + co.Ports = []sandbox.PortMapping{ + {ContainerPort: 9801, HostPort: 0, Protocol: "tcp"}, + } + }) + + waitForPort(t, box, 9801, 30*time.Second) + + 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 after retries: %v", err) + } + defer conn.Close() + + count := 0 + for event := range conn.Events { + if len(event) > 0 { + count++ + } + if count >= 2 { + break + } + } + if count < 2 { + t.Errorf("received %d events, want >= 2", count) + } + }) + } +} + +func TestVNCURL(t *testing.T) { + skipIfNoDocker(t) + + img := testImage() + if img == "alpine:latest" { + t.Skip("VNC test requires sandbox-v2-test image with VNC desktop") + } + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + co.VNC = true + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + url, err := box.VNC(ctx) + if err != nil { + t.Fatalf("VNC URL: %v", err) + } + if !strings.HasPrefix(url, "ws://") { + t.Fatalf("VNC URL = %q, want ws:// prefix", url) + } + t.Logf("VNC URL: %s", url) + }) + } +} + +func TestVNCConnect(t *testing.T) { + skipIfNoDocker(t) + + img := testImage() + if img == "alpine:latest" { + t.Skip("VNC test requires sandbox-v2-test image with VNC desktop") + } + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + co.VNC = true + }) + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + vncURL, err := box.VNC(ctx) + if err != nil { + t.Fatalf("VNC URL: %v", err) + } + t.Logf("VNC URL: %s", vncURL) + + waitForWSEndpoint(t, vncURL, 30*time.Second) + + dialer := websocket.Dialer{ + Subprotocols: []string{"binary"}, + HandshakeTimeout: 10 * time.Second, + } + 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 + } + 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() + + ws.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, msg, err := ws.ReadMessage() + if err != nil { + t.Fatalf("VNC read: %v", err) + } + if !strings.HasPrefix(string(msg), "RFB ") { + t.Fatalf("VNC banner = %q, want RFB prefix", string(msg)) + } + t.Logf("VNC banner: %s", strings.TrimSpace(string(msg))) + }) + } +} + +func waitForWSEndpoint(t *testing.T, wsURL string, timeout time.Duration) { + t.Helper() + httpURL := "http" + strings.TrimPrefix(wsURL, "ws") + if idx := strings.LastIndex(httpURL, "/ws"); idx > 0 { + httpURL = httpURL[:idx] + } + + host := strings.TrimPrefix(httpURL, "http://") + if i := strings.Index(host, "/"); i > 0 { + host = host[:i] + } + + deadline := time.After(timeout) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-deadline: + t.Fatalf("VNC endpoint %s not ready within %v", host, timeout) + case <-ticker.C: + conn, err := net.DialTimeout("tcp", host, 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 new file mode 100644 index 00000000..83f9677d --- /dev/null +++ b/sandbox/v2/box_test.go @@ -0,0 +1,215 @@ +package sandbox_test + +import ( + "context" + "io" + "strings" + "testing" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestBoxExec(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) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result, err := box.Exec(ctx, []string{"echo", "box-exec"}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if result.Stdout != "box-exec\n" { + t.Errorf("stdout = %q, want %q", result.Stdout, "box-exec\n") + } + }) + } +} + +func TestBoxExecWithOptions(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) + + ctx := context.Background() + result, err := box.Exec(ctx, []string{"pwd"}, + sandbox.WithWorkDir("/tmp"), + ) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if result.Stdout != "/tmp\n" { + t.Errorf("stdout = %q, want %q", result.Stdout, "/tmp\n") + } + }) + } +} + +func TestBoxStream(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) + + ctx := context.Background() + stream, err := box.Stream(ctx, []string{"sh", "-c", "echo line1; echo line2"}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + out, err := io.ReadAll(stream.Stdout) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(out) != "line1\nline2\n" { + t.Errorf("stdout = %q, want %q", string(out), "line1\nline2\n") + } + + code, err := stream.Wait() + if err != nil { + t.Fatalf("Wait: %v", err) + } + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } + }) + } +} + +func TestBoxWorkspace(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) + + ws := box.Workspace() + if ws == nil { + t.Skip("Workspace returned nil (volume not available)") + } + + content := []byte("package main\n") + if err := ws.WriteFile("main.go", content, 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + data, err := ws.ReadFile("main.go") + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != string(content) { + t.Errorf("content = %q, want %q", string(data), string(content)) + } + + if err := ws.MkdirAll("src/pkg", 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + entries, err := ws.ReadDir("src") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) == 0 { + t.Error("expected non-empty directory listing") + } + }) + } +} + +func TestBoxInfo(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) + + ctx := context.Background() + info, err := box.Info(ctx) + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.ID != box.ID() { + t.Errorf("ID = %q, want %q", info.ID, box.ID()) + } + if s := strings.ToLower(info.Status); s != "running" { + t.Errorf("status = %q, want running", info.Status) + } + if info.Owner != "test-user" { + t.Errorf("owner = %q, want test-user", info.Owner) + } + }) + } +} + +func TestBoxStopStart(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) + ctx := context.Background() + + if err := box.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + + if err := box.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + result, err := box.Exec(ctx, []string{"echo", "after-restart"}) + if err != nil { + t.Fatalf("Exec after restart: %v", err) + } + if result.Stdout != "after-restart\n" { + t.Errorf("stdout = %q", result.Stdout) + } + }) + } +} + +func TestBoxGetOrCreate(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + ctx := context.Background() + box1, err := m.GetOrCreate(ctx, sandbox.CreateOptions{ + ID: "goc-" + pc.Name, + Image: testImage(), + Owner: "test-user", + }) + if err != nil { + t.Fatalf("GetOrCreate first: %v", err) + } + defer m.Remove(ctx, box1.ID()) + + box2, err := m.GetOrCreate(ctx, sandbox.CreateOptions{ + ID: "goc-" + pc.Name, + Image: testImage(), + Owner: "test-user", + }) + if err != nil { + t.Fatalf("GetOrCreate second: %v", err) + } + if box2.ContainerID() != box1.ContainerID() { + t.Error("expected same container for GetOrCreate with same ID") + } + }) + } +} 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/config.go b/sandbox/v2/config.go new file mode 100644 index 00000000..8cc9973c --- /dev/null +++ b/sandbox/v2/config.go @@ -0,0 +1,5 @@ +package sandbox + +type Config struct { + Pool []Pool +} diff --git a/sandbox/v2/docker/base/Dockerfile b/sandbox/v2/docker/base/Dockerfile new file mode 100644 index 00000000..fc5bb715 --- /dev/null +++ b/sandbox/v2/docker/base/Dockerfile @@ -0,0 +1,39 @@ +# Sandbox V2 base image โ€” self-contained, no dependency on V1 sandbox-base +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Faster mirror for ARM64 +RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \ + 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 \ + zip unzip tar gzip \ + htop procps \ + sed gawk grep \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +RUN useradd -m -s /bin/bash sandbox && \ + chown -R sandbox:sandbox /workspace + +# yao-grpc binary (replaces yao-bridge from V1) +ARG TARGETARCH +COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc +RUN chmod +x /usr/local/bin/yao-grpc + +# openai-proxy: Anthropic Messages API โ†’ OpenAI Chat Completions API +COPY openai-proxy-${TARGETARCH} /usr/local/bin/openai-proxy +RUN chmod +x /usr/local/bin/openai-proxy + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +USER sandbox +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/sandbox/v2/docker/base/entrypoint.sh b/sandbox/v2/docker/base/entrypoint.sh new file mode 100755 index 00000000..1625a32d --- /dev/null +++ b/sandbox/v2/docker/base/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# V2 base entrypoint โ€” conditionally starts yao-grpc and openai-proxy + +if [ -n "$YAO_GRPC_ADDR" ] && [ -n "$YAO_SANDBOX_ID" ]; then + tail -f /dev/null | yao-grpc serve & +fi + +if [ -n "$OPENAI_PROXY_BACKEND" ]; then + openai-proxy & +fi + +exec "$@" diff --git a/sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go b/sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go new file mode 100644 index 00000000..13067567 --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go @@ -0,0 +1,7 @@ +package main + +import proxy "github.com/yaoapp/yao/sandbox/v2/docker/bin/openai-proxy" + +func main() { + proxy.Main() +} diff --git a/sandbox/v2/docker/bin/openai-proxy/convert.go b/sandbox/v2/docker/bin/openai-proxy/convert.go new file mode 100644 index 00000000..233bf2d6 --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/convert.go @@ -0,0 +1,419 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "strings" +) + +func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest { + maxTokens := req.MaxTokens + if s.config.Options != nil { + if mt, ok := s.config.Options["max_tokens"]; ok { + switch v := mt.(type) { + case float64: + maxTokens = int(v) + case int: + maxTokens = v + } + } + } + + temperature := req.Temperature + if s.config.Options != nil { + if temp, ok := s.config.Options["temperature"]; ok { + if v, ok := temp.(float64); ok { + temperature = &v + } + } + } + + openaiReq := &OpenAIRequest{ + Model: s.config.Model, + MaxTokens: maxTokens, + Stream: req.Stream, + Temperature: temperature, + TopP: req.TopP, + Stop: req.StopSequences, + } + + if s.config.Options != nil { + openaiReq.ExtraOptions = make(map[string]interface{}) + for k, v := range s.config.Options { + switch k { + case "max_tokens", "temperature", "model", "key", "proxy": + continue + default: + openaiReq.ExtraOptions[k] = v + } + } + } + + openaiReq.Messages = s.convertMessages(req.Messages, req.System) + + if len(req.Tools) > 0 { + openaiReq.Tools = s.convertTools(req.Tools) + } + + if req.ToolChoice != nil { + openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice) + } + + return openaiReq +} + +func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg { + var result []OpenAIMsg + + if system != nil { + systemText := extractSystemText(system) + if systemText != "" { + result = append(result, OpenAIMsg{ + Role: "system", + Content: systemText, + }) + } + } + + for _, msg := range msgs { + converted := s.convertMessage(msg) + result = append(result, converted...) + } + + return result +} + +func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg { + var result []OpenAIMsg + + switch content := msg.Content.(type) { + case string: + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: content, + }) + + case []interface{}: + var toolResults []ContentBlock + var otherContent []interface{} + + for _, item := range content { + block := parseContentBlock(item) + if block.Type == "tool_result" { + toolResults = append(toolResults, block) + } else { + otherContent = append(otherContent, item) + } + } + + for _, tr := range toolResults { + toolMsg := OpenAIMsg{ + Role: "tool", + ToolCallID: tr.ToolUseID, + Content: extractToolResultContent(tr.Content), + } + result = append(result, toolMsg) + } + + if len(otherContent) > 0 { + openaiContent := s.convertContentBlocks(otherContent) + if len(openaiContent) == 1 && openaiContent[0].Type == "text" { + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: openaiContent[0].Text, + }) + } else if len(openaiContent) > 0 { + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: openaiContent, + }) + } + } + + if msg.Role == "assistant" { + toolCalls := extractToolUseBlocks(content) + if len(toolCalls) > 0 { + found := false + for i := range result { + if result[i].Role == "assistant" { + result[i].ToolCalls = toolCalls + found = true + break + } + } + if !found { + result = append(result, OpenAIMsg{ + Role: "assistant", + Content: "", + ToolCalls: toolCalls, + }) + } + } + } + } + + return result +} + +func (s *Server) convertContentBlocks(blocks []interface{}) []OpenAIContent { + var result []OpenAIContent + + for _, item := range blocks { + block := parseContentBlock(item) + + switch block.Type { + case "text": + result = append(result, OpenAIContent{ + Type: "text", + Text: block.Text, + }) + + case "image": + if block.Source != nil { + imageURL := convertImageSource(block.Source) + result = append(result, OpenAIContent{ + Type: "image_url", + ImageURL: imageURL, + }) + } + + case "tool_use", "tool_result": + continue + } + } + + return result +} + +func convertImageSource(source *ImageSource) *OpenAIImageURL { + if source == nil { + return nil + } + + switch source.Type { + case "base64": + mediaType := source.MediaType + if mediaType == "" { + mediaType = "image/jpeg" + } + return &OpenAIImageURL{ + URL: fmt.Sprintf("data:%s;base64,%s", mediaType, source.Data), + } + case "url": + return &OpenAIImageURL{ + URL: source.URL, + } + } + + return nil +} + +func (s *Server) convertTools(tools []AnthropicTool) []OpenAITool { + var result []OpenAITool + for _, tool := range tools { + result = append(result, OpenAITool{ + Type: "function", + Function: OpenAIFunction{ + Name: tool.Name, + Description: tool.Description, + Parameters: tool.InputSchema, + }, + }) + } + return result +} + +func (s *Server) convertToolChoice(choice *AnthropicToolChoice) interface{} { + if choice == nil { + return nil + } + switch choice.Type { + case "auto": + return "auto" + case "any": + return "required" + case "tool": + return map[string]interface{}{ + "type": "function", + "function": map[string]string{ + "name": choice.Name, + }, + } + case "none": + return "none" + } + return "auto" +} + +func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse { + result := &AnthropicResponse{ + ID: generateID("msg_"), + Type: "message", + Role: "assistant", + Content: []ContentBlock{}, + Model: s.config.Model, + } + + if len(resp.Choices) > 0 { + choice := resp.Choices[0] + + if content, ok := choice.Message.Content.(string); ok && content != "" { + result.Content = append(result.Content, ContentBlock{ + Type: "text", + Text: content, + }) + } + + for _, tc := range choice.Message.ToolCalls { + var input interface{} + json.Unmarshal([]byte(tc.Function.Arguments), &input) + + result.Content = append(result.Content, ContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: input, + }) + } + + stopReason := mapFinishReason(choice.FinishReason) + result.StopReason = &stopReason + } + + if resp.Usage != nil { + result.Usage = &Usage{ + InputTokens: resp.Usage.PromptTokens, + OutputTokens: resp.Usage.CompletionTokens, + } + } else { + result.Usage = &Usage{InputTokens: 0, OutputTokens: 0} + } + + return result +} + +func extractSystemText(system interface{}) string { + switch s := system.(type) { + case string: + return s + case []interface{}: + var texts []string + for _, item := range s { + if block, ok := item.(map[string]interface{}); ok { + if text, ok := block["text"].(string); ok { + if strings.HasPrefix(text, "x-anthropic-") { + continue + } + texts = append(texts, text) + } + } + } + if len(texts) > 0 { + return strings.Join(texts, "\n\n") + } + } + return "" +} + +func parseContentBlock(item interface{}) ContentBlock { + var block ContentBlock + switch v := item.(type) { + case map[string]interface{}: + if t, ok := v["type"].(string); ok { + block.Type = t + } + if text, ok := v["text"].(string); ok { + block.Text = text + } + if id, ok := v["id"].(string); ok { + block.ID = id + } + if name, ok := v["name"].(string); ok { + block.Name = name + } + if input, ok := v["input"]; ok { + block.Input = input + } + if toolUseID, ok := v["tool_use_id"].(string); ok { + block.ToolUseID = toolUseID + } + if content, ok := v["content"]; ok { + block.Content = content + } + if isError, ok := v["is_error"].(bool); ok { + block.IsError = isError + } + if source, ok := v["source"].(map[string]interface{}); ok { + block.Source = parseImageSource(source) + } + } + return block +} + +func parseImageSource(source map[string]interface{}) *ImageSource { + if source == nil { + return nil + } + result := &ImageSource{} + if t, ok := source["type"].(string); ok { + result.Type = t + } + if mediaType, ok := source["media_type"].(string); ok { + result.MediaType = mediaType + } + if data, ok := source["data"].(string); ok { + result.Data = data + } + if url, ok := source["url"].(string); ok { + result.URL = url + } + return result +} + +func extractToolUseBlocks(content []interface{}) []OpenAIToolCall { + var result []OpenAIToolCall + for _, item := range content { + block := parseContentBlock(item) + if block.Type == "tool_use" { + args, _ := json.Marshal(block.Input) + result = append(result, OpenAIToolCall{ + ID: block.ID, + Type: "function", + Function: OpenAIFunctionCall{ + Name: block.Name, + Arguments: string(args), + }, + }) + } + } + return result +} + +func extractToolResultContent(content interface{}) string { + switch c := content.(type) { + case string: + return c + case []interface{}: + for _, item := range c { + if block, ok := item.(map[string]interface{}); ok { + if block["type"] == "text" { + if text, ok := block["text"].(string); ok { + return text + } + } + } + } + } + return "" +} + +func mapRole(role string) string { + switch role { + case "user": + return "user" + case "assistant": + return "assistant" + default: + return role + } +} diff --git a/sandbox/v2/docker/bin/openai-proxy/main.go b/sandbox/v2/docker/bin/openai-proxy/main.go new file mode 100644 index 00000000..bc1004aa --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/main.go @@ -0,0 +1,510 @@ +// Package proxy provides a lightweight API proxy that translates +// Anthropic Messages API to OpenAI Chat Completions API. +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// Config holds the proxy server configuration +type Config struct { + Port int + Backend string + Model string + APIKey string + Timeout int + Verbose bool + LogFile string + Options map[string]interface{} +} + +// Server is the API proxy server +type Server struct { + config *Config + client *http.Client +} + +// Main is the entry point for the proxy server +func Main() { + config := parseFlags() + if err := config.Validate(); err != nil { + log.Fatalf("Configuration error: %v", err) + } + + if config.LogFile != "" { + f, err := os.OpenFile(config.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to open log file: %v", err) + } + mw := io.MultiWriter(os.Stdout, f) + log.SetOutput(mw) + } + + server := NewServer(config) + addr := fmt.Sprintf(":%d", config.Port) + + log.Printf("OpenAI Proxy starting on %s", addr) + log.Printf("Backend: %s", config.Backend) + log.Printf("Model: %s", config.Model) + if len(config.Options) > 0 { + optBytes, _ := json.Marshal(config.Options) + log.Printf("Options: %s", string(optBytes)) + } + + http.HandleFunc("/v1/messages", server.handleMessages) + http.HandleFunc("/health", server.handleHealth) + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func parseFlags() *Config { + config := &Config{} + + flag.IntVar(&config.Port, "p", 0, "Listen port") + flag.IntVar(&config.Port, "port", 0, "Listen port") + flag.StringVar(&config.Backend, "b", "", "Backend API URL") + flag.StringVar(&config.Backend, "backend", "", "Backend API URL") + flag.StringVar(&config.Model, "m", "", "Backend model name") + flag.StringVar(&config.Model, "model", "", "Backend model name") + flag.StringVar(&config.APIKey, "k", "", "Backend API key") + flag.StringVar(&config.APIKey, "api-key", "", "Backend API key") + flag.IntVar(&config.Timeout, "t", 0, "Request timeout in seconds") + flag.IntVar(&config.Timeout, "timeout", 0, "Request timeout in seconds") + flag.BoolVar(&config.Verbose, "v", false, "Verbose logging") + flag.BoolVar(&config.Verbose, "verbose", false, "Verbose logging") + flag.StringVar(&config.LogFile, "l", "", "Log file path") + flag.StringVar(&config.LogFile, "log", "", "Log file path") + + flag.Parse() + + if config.Port == 0 { + if v := os.Getenv("OPENAI_PROXY_PORT"); v != "" { + config.Port, _ = strconv.Atoi(v) + } + } + if config.Port == 0 { + config.Port = 3456 + } + + if config.Backend == "" { + config.Backend = os.Getenv("OPENAI_PROXY_BACKEND") + } + + if config.Model == "" { + config.Model = os.Getenv("OPENAI_PROXY_MODEL") + } + + if config.APIKey == "" { + config.APIKey = os.Getenv("OPENAI_PROXY_API_KEY") + } + + if config.Timeout == 0 { + if v := os.Getenv("OPENAI_PROXY_TIMEOUT"); v != "" { + config.Timeout, _ = strconv.Atoi(v) + } + } + if config.Timeout == 0 { + config.Timeout = 300 + } + + if optionsStr := os.Getenv("OPENAI_PROXY_OPTIONS"); optionsStr != "" { + var options map[string]interface{} + if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { + log.Printf("Warning: failed to parse OPENAI_PROXY_OPTIONS: %v", err) + } else { + config.Options = options + } + } + + return config +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Backend == "" { + return fmt.Errorf("backend URL is required (-b or OPENAI_PROXY_BACKEND)") + } + if c.Model == "" { + return fmt.Errorf("model name is required (-m or OPENAI_PROXY_MODEL)") + } + if c.APIKey == "" { + return fmt.Errorf("API key is required (-k or OPENAI_PROXY_API_KEY)") + } + return nil +} + +// NewServer creates a new proxy server +func NewServer(config *Config) *Server { + return &Server{ + config: config, + client: &http.Client{ + Timeout: time.Duration(config.Timeout) * time.Second, + }, + } +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body") + return + } + defer r.Body.Close() + + if s.config.Verbose { + log.Printf("Received request: %s", string(body)) + } + + var anthropicReq AnthropicRequest + if err := json.Unmarshal(body, &anthropicReq); err != nil { + s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON") + return + } + + openaiReq := s.convertRequest(&anthropicReq) + + if anthropicReq.Stream { + s.handleStreamingRequest(w, openaiReq) + } else { + s.handleNonStreamingRequest(w, openaiReq) + } +} + +func (s *Server) handleNonStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) { + openaiReq.Stream = false + + resp, err := s.forwardRequest(openaiReq) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error()) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", "Failed to read backend response") + return + } + + if s.config.Verbose { + log.Printf("Backend response: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + var openaiResp OpenAIResponse + if err := json.Unmarshal(body, &openaiResp); err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", "Invalid backend response") + return + } + + anthropicResp := s.convertResponse(&openaiResp) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(anthropicResp) +} + +func (s *Server) handleStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) { + openaiReq.Stream = true + openaiReq.StreamOptions = &StreamOptions{IncludeUsage: true} + + resp, err := s.forwardRequest(openaiReq) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + s.errorResponse(w, http.StatusInternalServerError, "server_error", "Streaming not supported") + return + } + + msgID := generateID("msg_") + startEvent := AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []ContentBlock{}, + Model: s.config.Model, + StopReason: nil, + StopSequence: nil, + Usage: &Usage{InputTokens: 0, OutputTokens: 0}, + }, + } + s.writeSSE(w, flusher, startEvent) + + s.processStream(w, flusher, resp.Body, msgID) +} + +func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body io.Reader, msgID string) { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + var contentBlockStarted bool + var currentToolCall *ToolCallAccumulator + var toolCalls []*ToolCallAccumulator + var contentIndex int + var finishReason string + var lastUsage *Usage + + for scanner.Scan() { + line := scanner.Text() + + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk OpenAIStreamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if s.config.Verbose { + log.Printf("Failed to parse chunk: %s", data) + } + continue + } + + if len(chunk.Choices) == 0 { + if chunk.Usage != nil { + lastUsage = &Usage{ + InputTokens: chunk.Usage.PromptTokens, + OutputTokens: chunk.Usage.CompletionTokens, + } + } + continue + } + + choice := chunk.Choices[0] + + if choice.FinishReason != "" { + finishReason = mapFinishReason(choice.FinishReason) + } + + if len(choice.Delta.ToolCalls) > 0 { + for _, tc := range choice.Delta.ToolCalls { + if tc.Index != nil { + idx := *tc.Index + if idx >= len(toolCalls) { + if contentBlockStarted && currentToolCall == nil { + stopEvent := AnthropicStreamEvent{ + Type: "content_block_stop", + Index: contentIndex - 1, + } + s.writeSSE(w, flusher, stopEvent) + } + + currentToolCall = &ToolCallAccumulator{ + Index: idx, + ID: tc.ID, + Name: tc.Function.Name, + Args: "", + } + toolCalls = append(toolCalls, currentToolCall) + + startEvent := AnthropicStreamEvent{ + Type: "content_block_start", + Index: contentIndex, + ContentBlock: &ContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: map[string]interface{}{}, + }, + } + s.writeSSE(w, flusher, startEvent) + contentIndex++ + } + + if tc.Function.Arguments != "" { + currentToolCall.Args += tc.Function.Arguments + deltaEvent := AnthropicStreamEvent{ + Type: "content_block_delta", + Index: contentIndex - 1, + Delta: &DeltaContent{ + Type: "input_json_delta", + PartialJSON: tc.Function.Arguments, + }, + } + s.writeSSE(w, flusher, deltaEvent) + } + } + } + continue + } + + if choice.Delta.Content != "" { + if !contentBlockStarted { + startEvent := AnthropicStreamEvent{ + Type: "content_block_start", + Index: contentIndex, + ContentBlock: &ContentBlock{ + Type: "text", + Text: "", + }, + } + s.writeSSE(w, flusher, startEvent) + contentBlockStarted = true + contentIndex++ + } + + deltaEvent := AnthropicStreamEvent{ + Type: "content_block_delta", + Index: contentIndex - 1, + Delta: &DeltaContent{ + Type: "text_delta", + Text: choice.Delta.Content, + }, + } + s.writeSSE(w, flusher, deltaEvent) + } + } + + if contentBlockStarted || len(toolCalls) > 0 { + stopEvent := AnthropicStreamEvent{ + Type: "content_block_stop", + Index: contentIndex - 1, + } + s.writeSSE(w, flusher, stopEvent) + } + + if finishReason == "" { + finishReason = "end_turn" + } + if lastUsage == nil { + lastUsage = &Usage{InputTokens: 0, OutputTokens: 0} + } + deltaEvent := AnthropicStreamEvent{ + Type: "message_delta", + Delta: &DeltaContent{ + StopReason: &finishReason, + }, + Usage: lastUsage, + } + s.writeSSE(w, flusher, deltaEvent) + + stopEvent := AnthropicStreamEvent{ + Type: "message_stop", + } + s.writeSSE(w, flusher, stopEvent) +} + +func (s *Server) writeSSE(w http.ResponseWriter, flusher http.Flusher, event interface{}) { + data, err := json.Marshal(event) + if err != nil { + return + } + + eventType := "" + if e, ok := event.(AnthropicStreamEvent); ok { + eventType = e.Type + } + + if eventType != "" { + fmt.Fprintf(w, "event: %s\n", eventType) + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + if s.config.Verbose { + log.Printf("SSE event: %s", string(data)) + } +} + +func (s *Server) forwardRequest(openaiReq *OpenAIRequest) (*http.Response, error) { + body, err := json.Marshal(openaiReq) + if err != nil { + return nil, err + } + + if s.config.Verbose { + log.Printf("Forwarding to backend: %s", string(body)) + } + + req, err := http.NewRequest(http.MethodPost, s.config.Backend, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.config.APIKey) + + return s.client.Do(req) +} + +func (s *Server) errorResponse(w http.ResponseWriter, status int, errType, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]string{ + "type": errType, + "message": message, + }, + }) +} + +func generateID(prefix string) string { + return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) +} + +func mapFinishReason(reason string) string { + switch reason { + case "stop": + return "end_turn" + case "length": + return "max_tokens" + case "tool_calls", "function_call": + return "tool_use" + case "content_filter": + return "end_turn" + default: + return "end_turn" + } +} diff --git a/sandbox/v2/docker/bin/openai-proxy/types.go b/sandbox/v2/docker/bin/openai-proxy/types.go new file mode 100644 index 00000000..e62989be --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/types.go @@ -0,0 +1,244 @@ +package proxy + +import "encoding/json" + +// ============================================ +// Anthropic API Types +// ============================================ + +type AnthropicRequest struct { + Model string `json:"model"` + Messages []AnthropicMsg `json:"messages"` + System interface{} `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []AnthropicTool `json:"tools,omitempty"` + ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type AnthropicMsg struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *ImageSource `json:"source,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input interface{} `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content interface{} `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +type ImageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type,omitempty"` + Data string `json:"data,omitempty"` + URL string `json:"url,omitempty"` +} + +type SystemBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type AnthropicTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema interface{} `json:"input_schema"` +} + +type AnthropicToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +type AnthropicResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []ContentBlock `json:"content"` + Model string `json:"model"` + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage *Usage `json:"usage"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type AnthropicStreamEvent struct { + Type string `json:"type"` + Index int `json:"index,omitempty"` + Message *AnthropicResponse `json:"message,omitempty"` + ContentBlock *ContentBlock `json:"content_block,omitempty"` + Delta *DeltaContent `json:"delta,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +type DeltaContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` +} + +// ============================================ +// OpenAI API Types +// ============================================ + +type OpenAIRequest struct { + Model string `json:"model"` + Messages []OpenAIMsg `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` + Tools []OpenAITool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + ExtraOptions map[string]interface{} `json:"-"` +} + +func (r OpenAIRequest) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{ + "model": r.Model, + "messages": r.Messages, + } + if r.MaxTokens > 0 { + m["max_tokens"] = r.MaxTokens + } + if r.Stream { + m["stream"] = r.Stream + } + if r.StreamOptions != nil { + m["stream_options"] = r.StreamOptions + } + if r.Temperature != nil { + m["temperature"] = *r.Temperature + } + if r.TopP != nil { + m["top_p"] = *r.TopP + } + if len(r.Stop) > 0 { + m["stop"] = r.Stop + } + if len(r.Tools) > 0 { + m["tools"] = r.Tools + } + if r.ToolChoice != nil { + m["tool_choice"] = r.ToolChoice + } + for k, v := range r.ExtraOptions { + if _, exists := m[k]; !exists { + m[k] = v + } + } + return json.Marshal(m) +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +type OpenAIMsg struct { + Role string `json:"role"` + Content interface{} `json:"content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type OpenAIContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *OpenAIImageURL `json:"image_url,omitempty"` +} + +type OpenAIImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +type OpenAITool struct { + Type string `json:"type"` + Function OpenAIFunction `json:"function"` +} + +type OpenAIFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters interface{} `json:"parameters"` +} + +type OpenAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function OpenAIFunctionCall `json:"function"` + Index *int `json:"index,omitempty"` +} + +type OpenAIFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type OpenAIResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +type OpenAIChoice struct { + Index int `json:"index"` + Message OpenAIMsg `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type OpenAIStreamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIStreamChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +type OpenAIStreamChoice struct { + Index int `json:"index"` + Delta OpenAIStreamDelta `json:"delta"` + FinishReason string `json:"finish_reason,omitempty"` +} + +type OpenAIStreamDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` +} + +type ToolCallAccumulator struct { + Index int + ID string + Name string + Args string +} diff --git a/sandbox/v2/docker/build.sh b/sandbox/v2/docker/build.sh new file mode 100755 index 00000000..f8dd7ab4 --- /dev/null +++ b/sandbox/v2/docker/build.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Build script for Sandbox V2 Docker images (base + test) +# Usage: ./build.sh [true|false] โ€” push to registry or build locally + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUSH=${1:-false} +REGISTRY=${REGISTRY:-"yaoapp"} +YAO_ROOT="$SCRIPT_DIR/../../.." + +echo "=== Building Sandbox V2 Images ===" +echo "Push: $PUSH" +echo "Registry: $REGISTRY" + +# --- Cross-compile Go binaries --- + +echo "" +echo "=== Building yao-grpc (multi-arch) ===" +cd "$YAO_ROOT/tai/grpc/cmd" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/yao-grpc-amd64" . +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/yao-grpc-arm64" . +echo "Built: yao-grpc-amd64, yao-grpc-arm64" + +echo "" +echo "=== Building openai-proxy (multi-arch) ===" +cd "$SCRIPT_DIR/bin/openai-proxy/cmd/openai-proxy" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-amd64" . +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-arm64" . +echo "Built: openai-proxy-amd64, openai-proxy-arm64" + +cd "$SCRIPT_DIR" + +# --- Setup buildx --- + +BUILDER_NAME="yao-multiarch" +if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then + echo "Creating buildx builder: $BUILDER_NAME" + docker buildx create --name "$BUILDER_NAME" --use --bootstrap +else + docker buildx use "$BUILDER_NAME" +fi + +build_image() { + local IMAGE_NAME=$1 + local CONTEXT_DIR=$2 + local PUSH_FLAG=$3 + + echo "" + echo "=== Building $IMAGE_NAME (linux/amd64,linux/arm64) ===" + + local BUILD_ARGS="--platform linux/amd64,linux/arm64 -t ${REGISTRY}/${IMAGE_NAME}:latest" + + if [ "$PUSH_FLAG" = "true" ]; then + BUILD_ARGS="$BUILD_ARGS --push" + else + echo "Note: Multi-arch build without push. Building for current platform only." + BUILD_ARGS="--load -t ${REGISTRY}/${IMAGE_NAME}:latest" + fi + + docker buildx build $BUILD_ARGS -f "$CONTEXT_DIR/Dockerfile" "$CONTEXT_DIR" +} + +# --- Build images --- + +build_image "sandbox-v2-base" "$SCRIPT_DIR/base" "$PUSH" +build_image "sandbox-v2-test" "$SCRIPT_DIR/test" "$PUSH" + +# --- Cleanup binaries --- + +echo "" +echo "=== Cleanup ===" +rm -f "$SCRIPT_DIR/base/yao-grpc-amd64" "$SCRIPT_DIR/base/yao-grpc-arm64" +rm -f "$SCRIPT_DIR/base/openai-proxy-amd64" "$SCRIPT_DIR/base/openai-proxy-arm64" +echo "Removed temporary binary files" + +echo "" +echo "=== Build complete ===" +docker images | grep -E "sandbox-v2" | head -10 || true diff --git a/sandbox/v2/docker/test/Dockerfile b/sandbox/v2/docker/test/Dockerfile new file mode 100644 index 00000000..697fc551 --- /dev/null +++ b/sandbox/v2/docker/test/Dockerfile @@ -0,0 +1,28 @@ +# Sandbox V2 test image โ€” adds test services + VNC desktop on top of v2-base +FROM yaoapp/sandbox-v2-base:latest + +USER root + +RUN apt-get update && apt-get install -y --no-install-recommends \ + nginx \ + python3 \ + python3-pip \ + xvfb \ + x11vnc \ + fluxbox \ + xterm \ + && pip3 install --break-system-packages websockets websockify \ + && rm -rf /var/lib/apt/lists/* + +# Test service scripts +COPY ws-echo.py /opt/test/ws-echo.py +COPY sse-server.py /opt/test/sse-server.py +COPY entrypoint.sh /test-entrypoint.sh +RUN chmod +x /test-entrypoint.sh + +ENV DISPLAY=:99 + +USER sandbox +EXPOSE 5900 6080 +ENTRYPOINT ["/usr/bin/tini", "--", "/test-entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/sandbox/v2/docker/test/entrypoint.sh b/sandbox/v2/docker/test/entrypoint.sh new file mode 100755 index 00000000..8d7ea620 --- /dev/null +++ b/sandbox/v2/docker/test/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# V2 test entrypoint โ€” starts test services + VNC desktop then delegates to base entrypoint + +# Start Xvfb (virtual framebuffer) +Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset & +sleep 0.5 + +# Start fluxbox window manager +fluxbox & + +# Start x11vnc (raw RFB on 5900) +x11vnc -display :99 -rfbport 5900 -nopw -shared -forever -xkb -ncache 10 & +sleep 0.3 + +# Start websockify (WebSocket on 6080 โ†’ RFB 5900) +websockify 0.0.0.0:6080 localhost:5900 & + +# Test services +python3 /opt/test/ws-echo.py & +python3 /opt/test/sse-server.py & + +exec /entrypoint.sh "$@" diff --git a/sandbox/v2/docker/test/sse-server.py b/sandbox/v2/docker/test/sse-server.py new file mode 100644 index 00000000..c1b06fa0 --- /dev/null +++ b/sandbox/v2/docker/test/sse-server.py @@ -0,0 +1,28 @@ +"""Minimal SSE server on port 9801 using only stdlib. +Sends a 'hello' event every second, up to 5 events then closes.""" +import http.server +import time + +class SSEHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.end_headers() + + for i in range(5): + msg = f"data: hello-{i}\n\n" + try: + self.wfile.write(msg.encode()) + self.wfile.flush() + except BrokenPipeError: + return + time.sleep(0.2) + + def log_message(self, format, *args): + pass + +if __name__ == "__main__": + server = http.server.HTTPServer(("0.0.0.0", 9801), SSEHandler) + server.serve_forever() diff --git a/sandbox/v2/docker/test/ws-echo.py b/sandbox/v2/docker/test/ws-echo.py new file mode 100644 index 00000000..d551fb61 --- /dev/null +++ b/sandbox/v2/docker/test/ws-echo.py @@ -0,0 +1,14 @@ +"""WebSocket echo server on port 9800 using the websockets library.""" +import asyncio +import websockets + +async def echo(ws): + async for msg in ws: + await ws.send(msg) + +async def main(): + async with websockets.serve(echo, "0.0.0.0", 9800): + await asyncio.Future() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sandbox/v2/errors.go b/sandbox/v2/errors.go new file mode 100644 index 00000000..deda6ea7 --- /dev/null +++ b/sandbox/v2/errors.go @@ -0,0 +1,11 @@ +package sandbox + +import "errors" + +var ( + ErrNotAvailable = errors.New("sandbox: not available (no pools configured)") + ErrNotFound = errors.New("sandbox: not found") + ErrLimitExceeded = errors.New("sandbox: limit exceeded") + ErrPoolNotFound = errors.New("sandbox: pool not found") + ErrPoolInUse = errors.New("sandbox: pool has running boxes") +) diff --git a/sandbox/v2/export_test.go b/sandbox/v2/export_test.go new file mode 100644 index 00000000..65519b30 --- /dev/null +++ b/sandbox/v2/export_test.go @@ -0,0 +1,6 @@ +package sandbox + +// ResetForTest resets the global manager for testing purposes. +func ResetForTest() { + mgr = nil +} diff --git a/sandbox/v2/grpc.go b/sandbox/v2/grpc.go new file mode 100644 index 00000000..522f0123 --- /dev/null +++ b/sandbox/v2/grpc.go @@ -0,0 +1,54 @@ +package sandbox + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +func createToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// CreateContainerTokens creates an OAuth token pair for a sandbox container. +func CreateContainerTokens(sandboxID, owner string, scopes []string) (access, refresh string, err error) { + access, err = createToken() + if err != nil { + return "", "", err + } + refresh, err = createToken() + if err != nil { + return "", "", err + } + return access, refresh, nil +} + +// RevokeContainerTokens revokes a refresh token for a sandbox container. +func RevokeContainerTokens(refresh string) error { + return nil +} + +// BuildGRPCEnv builds the gRPC environment variables for a sandbox container. +func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) map[string]string { + portStr := strconv.Itoa(grpcPort) + env := map[string]string{ + "YAO_SANDBOX_ID": sandboxID, + "YAO_TOKEN": access, + "YAO_REFRESH_TOKEN": refresh, + } + if pool != nil && strings.Contains(pool.Addr, "tai://") { + taiHost := strings.TrimPrefix(pool.Addr, "tai://") + env["YAO_GRPC_TAI"] = "enable" + env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:9100", taiHost) + env["YAO_GRPC_UPSTREAM"] = fmt.Sprintf("127.0.0.1:%s", portStr) + } else { + env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr) + } + return env +} diff --git a/sandbox/v2/grpc_test.go b/sandbox/v2/grpc_test.go new file mode 100644 index 00000000..8e567e24 --- /dev/null +++ b/sandbox/v2/grpc_test.go @@ -0,0 +1,56 @@ +package sandbox_test + +import ( + "testing" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestBuildGRPCEnvLocal(t *testing.T) { + pool := &sandbox.Pool{Name: "local", Addr: "local"} + env := sandbox.BuildGRPCEnv(pool, "sb-001", "access-tok", "refresh-tok", 9099) + + if env["YAO_SANDBOX_ID"] != "sb-001" { + t.Errorf("YAO_SANDBOX_ID = %q", env["YAO_SANDBOX_ID"]) + } + if env["YAO_TOKEN"] != "access-tok" { + t.Errorf("YAO_TOKEN = %q", env["YAO_TOKEN"]) + } + if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { + t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"]) + } + if _, ok := env["YAO_GRPC_TAI"]; ok { + t.Error("local mode should not set YAO_GRPC_TAI") + } +} + +func TestBuildGRPCEnvRemote(t *testing.T) { + pool := &sandbox.Pool{Name: "gpu", Addr: "tai://gpu-server"} + env := sandbox.BuildGRPCEnv(pool, "sb-002", "access", "refresh", 9099) + + if env["YAO_GRPC_TAI"] != "enable" { + t.Errorf("YAO_GRPC_TAI = %q, want enable", env["YAO_GRPC_TAI"]) + } + if env["YAO_GRPC_ADDR"] != "gpu-server:9100" { + t.Errorf("YAO_GRPC_ADDR = %q", env["YAO_GRPC_ADDR"]) + } + if env["YAO_GRPC_UPSTREAM"] != "127.0.0.1:9099" { + t.Errorf("YAO_GRPC_UPSTREAM = %q", env["YAO_GRPC_UPSTREAM"]) + } +} + +func TestCreateContainerTokens(t *testing.T) { + access, refresh, err := sandbox.CreateContainerTokens("sb-001", "user1", nil) + if err != nil { + t.Fatalf("CreateContainerTokens: %v", err) + } + if len(access) != 64 { + t.Errorf("access token len = %d, want 64 hex chars", len(access)) + } + if len(refresh) != 64 { + t.Errorf("refresh token len = %d, want 64 hex chars", len(refresh)) + } + if access == refresh { + t.Error("access and refresh tokens should be different") + } +} diff --git a/sandbox/v2/jsapi/box.go b/sandbox/v2/jsapi/box.go new file mode 100644 index 00000000..e93b5d84 --- /dev/null +++ b/sandbox/v2/jsapi/box.go @@ -0,0 +1,129 @@ +package jsapi + +import ( + "rogchap.com/v8go" +) + +// NewBoxObject creates a JS Box object backed by a sandbox ID string. +// All methods delegate to the Go sandbox.M() singleton โ€” no Go object is +// passed to V8, no bridge registration, no Release() needed. +// +// # Properties (read-only) +// +// box.id โ†’ string // sandbox ID โ† Box.ID() +// box.owner โ†’ string // owner user ID โ† Box.Owner() +// box.pool โ†’ string // pool name โ† Box.Pool() +// +// # Methods โ€” Go mapping +// +// box.Exec(cmd, options?) โ†’ ExecResult +// +// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error) +// +// JS args: +// cmd: string[] โ†’ cmd []string +// options: { โ†’ ExecOption functional options +// workdir: string, โ†’ WithWorkDir(dir) +// env: object, โ†’ WithEnv(map[string]string) +// timeout: number โ†’ WithTimeout(ms โ†’ time.Duration) +// } +// JS returns: { +// exit_code: number, โ† ExecResult.ExitCode +// stdout: string, โ† ExecResult.Stdout +// stderr: string โ† ExecResult.Stderr +// } +// +// box.Stream(cmd, options?) โ†’ ExecStream +// +// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error) +// +// JS returns: { +// stdout: ReadableStream, โ† ExecStream.Stdout +// stderr: ReadableStream, โ† ExecStream.Stderr +// stdin: WritableStream, โ† ExecStream.Stdin +// wait: function() โ†’ number, โ† ExecStream.Wait() (int, error) +// cancel: function() โ†’ void โ† ExecStream.Cancel() +// } +// +// box.Attach(port, options?) โ†’ ServiceConn +// +// Go: Box.Attach(ctx, port int, opts ...AttachOption) (*ServiceConn, error) +// +// JS args: +// port: number โ†’ port int +// options: { โ†’ AttachOption functional options +// protocol: "ws"|"sse", โ†’ WithProtocol(protocol) +// path: string, โ†’ WithPath(path) +// headers: object โ†’ WithHeaders(map[string]string) +// } +// JS returns: { +// url: string, โ† ServiceConn.URL +// read: function() โ†’ Uint8Array, โ† ServiceConn.Read() ([]byte, error) +// write: function(data) โ†’ void, โ† ServiceConn.Write(data) error +// events: AsyncIterable, โ† ServiceConn.Events <-chan []byte +// close: function() โ†’ void โ† ServiceConn.Close() error +// } +// +// box.VNC() โ†’ string +// +// Go: Box.VNC(ctx) (string, error) +// Returns: VNC WebSocket URL +// +// box.Proxy(port, path?) โ†’ string +// +// Go: Box.Proxy(ctx, port int, path string) (string, error) +// Returns: HTTP proxy URL +// +// box.Workspace() โ†’ WorkspaceFS +// +// Go: Box.Workspace() workspace.FS +// Box.WorkspaceID() string +// Returns: WorkspaceFS object (see workspace/jsapi/fs.go) +// Uses box.WorkspaceID() to create NewFSObject +// +// box.Info() โ†’ BoxInfo +// +// Go: Box.Info(ctx) (*BoxInfo, error) +// JS returns: { +// id: string, โ† BoxInfo.ID +// container_id: string, โ† BoxInfo.ContainerID +// pool: string, โ† BoxInfo.Pool +// owner: string, โ† BoxInfo.Owner +// status: string, โ† BoxInfo.Status +// image: string, โ† BoxInfo.Image +// vnc: boolean, โ† BoxInfo.VNC +// policy: string, โ† BoxInfo.Policy (LifecyclePolicy) +// labels: object, โ† BoxInfo.Labels (map[string]string) +// created_at: string, โ† BoxInfo.CreatedAt (ISO 8601) +// last_active: string, โ† BoxInfo.LastActive (ISO 8601) +// process_count: number โ† BoxInfo.ProcessCount +// } +// +// box.Start() โ†’ void +// +// Go: Box.Start(ctx) error +// +// box.Stop() โ†’ void +// +// Go: Box.Stop(ctx) error +// +// box.Remove() โ†’ void +// +// Go: Box.Remove(ctx) error +func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) { + // TODO: Phase 2 implementation + // 1. Create JS object via v8go.NewObjectTemplate + // 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID)) + // 3. Bind each method as FunctionTemplate: + // - Exec โ†’ sandbox.M().Get(id).Exec(ctx, cmd, opts...) + // - Stream โ†’ sandbox.M().Get(id).Stream(ctx, cmd, opts...) + // - Attach โ†’ sandbox.M().Get(id).Attach(ctx, port, opts...) + // - VNC โ†’ sandbox.M().Get(id).VNC(ctx) + // - Proxy โ†’ sandbox.M().Get(id).Proxy(ctx, port, path) + // - Workspace โ†’ NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID()) + // - Info โ†’ sandbox.M().Get(id).Info(ctx) โ†’ JS object + // - Start โ†’ sandbox.M().Get(id).Start(ctx) + // - Stop โ†’ sandbox.M().Get(id).Stop(ctx) + // - Remove โ†’ sandbox.M().Get(id).Remove(ctx) + return nil, nil +} diff --git a/sandbox/v2/jsapi/jsapi.go b/sandbox/v2/jsapi/jsapi.go new file mode 100644 index 00000000..84c71337 --- /dev/null +++ b/sandbox/v2/jsapi/jsapi.go @@ -0,0 +1,155 @@ +// Package jsapi registers the sandbox namespace into the Yao V8 runtime. +// +// All methods are static on the sandbox object โ€” no constructor. +// +// # JavaScript API +// +// const box = sandbox.Create({ image: "node:20", owner: "user1" }) +// const result = box.Exec(["node", "-e", "console.log('hi')"]) +// console.log(result.stdout) +// +// const box = sandbox.Get(id) // โ†’ Box +// const list = sandbox.List({ owner: "u1" }) // โ†’ BoxInfo[] +// sandbox.Delete(id) // โ†’ void +// +// # Go mapping +// +// sandbox.Create(opts) โ†’ Manager.Create(ctx, CreateOptions) โ†’ Box +// sandbox.Create(opts) โ†’ Manager.GetOrCreate(ctx, opts) โ†’ Box (when opts.id is set) +// sandbox.Get(id) โ†’ Manager.Get(ctx, id) โ†’ Box +// sandbox.List(filter?) โ†’ Manager.List(ctx, ListOptions) โ†’ []*Box โ†’ BoxInfo[] +// sandbox.Delete(id) โ†’ Manager.Remove(ctx, id) โ†’ void +// +// Registration happens via init() โ€” import with: +// +// _ "github.com/yaoapp/yao/sandbox/v2/jsapi" +package jsapi + +import ( + v8 "github.com/yaoapp/gou/runtime/v8" + "rogchap.com/v8go" +) + +func init() { + v8.RegisterObject("sandbox", ExportObject) +} + +// ExportObject exports the sandbox namespace object to V8. +func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate { + obj := v8go.NewObjectTemplate(iso) + obj.Set("Create", v8go.NewFunctionTemplate(iso, sbCreate)) + obj.Set("Get", v8go.NewFunctionTemplate(iso, sbGet)) + obj.Set("List", v8go.NewFunctionTemplate(iso, sbList)) + obj.Set("Delete", v8go.NewFunctionTemplate(iso, sbDelete)) + return obj +} + +// sbCreate: `sandbox.Create(options)` โ†’ Box +// +// Go: Manager.Create(ctx, CreateOptions) (*Box, error) +// +// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) โ€” when opts.id is set +// +// JS options โ†’ Go CreateOptions mapping: +// +// { +// id: string โ†’ CreateOptions.ID // optional; triggers GetOrCreate +// owner: string โ†’ CreateOptions.Owner // required +// pool: string โ†’ CreateOptions.Pool // default: first pool +// image: string โ†’ CreateOptions.Image // required +// workdir: string โ†’ CreateOptions.WorkDir +// user: string โ†’ CreateOptions.User // e.g. "1000:1000" +// env: object โ†’ CreateOptions.Env // map[string]string +// memory: number โ†’ CreateOptions.Memory // bytes (int64) +// cpus: number โ†’ CreateOptions.CPUs // float64 e.g. 1.5 +// vnc: boolean โ†’ CreateOptions.VNC +// ports: array โ†’ CreateOptions.Ports // [{container, host, host_ip, protocol}] โ†’ []PortMapping +// policy: string โ†’ CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent" +// idle_timeout: number โ†’ CreateOptions.IdleTimeout // ms โ†’ time.Duration +// stop_timeout: number โ†’ CreateOptions.StopTimeout // ms โ†’ time.Duration +// workspace_id: string โ†’ CreateOptions.WorkspaceID +// mount_mode: string โ†’ CreateOptions.MountMode // "rw"|"ro" +// mount_path: string โ†’ CreateOptions.MountPath +// labels: object โ†’ CreateOptions.Labels // map[string]string +// } +// +// Returns: Box object (see box.go) +func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse options from info.Args()[0] + // 2. Validate required fields (image, owner) + // 3. If opts.id != "" โ†’ sandbox.M().GetOrCreate(ctx, opts) + // else โ†’ sandbox.M().Create(ctx, opts) + // 4. Return NewBoxObject(v8ctx, box.ID()) + return v8go.Undefined(info.Context().Isolate()) +} + +// sbGet: `sandbox.Get(id)` โ†’ Box | null +// +// Go: Manager.Get(ctx, id) (*Box, error) +// +// Args: +// +// id: string โ€” sandbox ID +// +// Returns: Box object if found, null if not found +func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. box, err := sandbox.M().Get(ctx, id) + // 3. Return NewBoxObject(v8ctx, id) or null + return v8go.Undefined(info.Context().Isolate()) +} + +// sbList: `sandbox.List(filter?)` โ†’ BoxInfo[] +// +// Go: Manager.List(ctx, ListOptions) ([]*Box, error) +// +// then Box.Info(ctx) for each โ†’ BoxInfo +// +// JS filter โ†’ Go ListOptions mapping: +// +// { +// owner: string โ†’ ListOptions.Owner // filter by owner; empty = all +// pool: string โ†’ ListOptions.Pool // filter by pool; empty = all +// labels: object โ†’ ListOptions.Labels // filter by labels +// } +// +// Returns: BoxInfo[] โ€” each element: +// +// { +// id: string โ† BoxInfo.ID +// container_id: string โ† BoxInfo.ContainerID +// pool: string โ† BoxInfo.Pool +// owner: string โ† BoxInfo.Owner +// status: string โ† BoxInfo.Status +// image: string โ† BoxInfo.Image +// vnc: boolean โ† BoxInfo.VNC +// policy: string โ† BoxInfo.Policy +// labels: object โ† BoxInfo.Labels +// created_at: string โ† BoxInfo.CreatedAt (ISO 8601) +// last_active: string โ† BoxInfo.LastActive (ISO 8601) +// process_count: number โ† BoxInfo.ProcessCount +// } +func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse optional filter from info.Args()[0] + // 2. boxes := sandbox.M().List(ctx, opts) + // 3. For each box: box.Info(ctx) โ†’ BoxInfo โ†’ JS object + // 4. Return JS array of BoxInfo objects + return v8go.Undefined(info.Context().Isolate()) +} + +// sbDelete: `sandbox.Delete(id)` โ†’ void +// +// Go: Manager.Remove(ctx, id) error +// +// Args: +// +// id: string โ€” sandbox ID to remove +func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. sandbox.M().Remove(ctx, id) + return v8go.Undefined(info.Context().Isolate()) +} diff --git a/sandbox/v2/manager.go b/sandbox/v2/manager.go new file mode 100644 index 00000000..4779bd14 --- /dev/null +++ b/sandbox/v2/manager.go @@ -0,0 +1,616 @@ +package sandbox + +import ( + "context" + "fmt" + "sync" + "time" + + "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. +type Manager struct { + pool map[string]*tai.Client + poolDefs []Pool + defaultPool string + config Config + boxes sync.Map + mu sync.Mutex + cancel context.CancelFunc + grpcPort int + wsManager *workspace.Manager +} + +func newManager(cfg Config) (*Manager, error) { + m := &Manager{ + pool: make(map[string]*tai.Client), + poolDefs: cfg.Pool, + config: cfg, + grpcPort: 9099, + } + if len(cfg.Pool) > 0 { + m.defaultPool = cfg.Pool[0].Name + } + return m, nil +} + +// Start discovers existing containers from all pools, rebuilds the boxes map, +// and starts the cleanup loop. +func (m *Manager) Start(ctx context.Context) error { + if len(m.poolDefs) == 0 { + return nil + } + + for _, pd := range m.poolDefs { + client, err := m.getPool(pd.Name) + if err != nil { + continue + } + m.recoverBoxes(ctx, &pd, client) + } + + loopCtx, cancel := context.WithCancel(ctx) + m.cancel = cancel + go m.cleanupLoop(loopCtx) + return nil +} + +// AddPool registers a new pool at runtime. +func (m *Manager) AddPool(_ context.Context, p Pool) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, pd := range m.poolDefs { + if pd.Name == p.Name { + return fmt.Errorf("sandbox: pool %q already exists", p.Name) + } + } + m.poolDefs = append(m.poolDefs, p) + if m.defaultPool == "" { + m.defaultPool = p.Name + } + return nil +} + +// RemovePool removes a pool by name. +func (m *Manager) RemovePool(ctx context.Context, name string, force bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + idx := -1 + for i, pd := range m.poolDefs { + if pd.Name == name { + idx = i + break + } + } + if idx < 0 { + return ErrPoolNotFound + } + + count := 0 + m.boxes.Range(func(_, value any) bool { + if value.(*Box).pool == name { + count++ + } + return true + }) + + if count > 0 && !force { + return ErrPoolInUse + } + + if count > 0 { + m.boxes.Range(func(key, value any) bool { + b := value.(*Box) + if b.pool == name { + b.Remove(ctx) + } + return true + }) + } + + m.poolDefs = append(m.poolDefs[:idx], m.poolDefs[idx+1:]...) + if client, ok := m.pool[name]; ok { + client.Close() + delete(m.pool, name) + } + return nil +} + +// Pools returns all registered pool names and their status. +func (m *Manager) Pools() []PoolInfo { + m.mu.Lock() + defer m.mu.Unlock() + + result := make([]PoolInfo, 0, len(m.poolDefs)) + for _, pd := range m.poolDefs { + _, connected := m.pool[pd.Name] + count := 0 + m.boxes.Range(func(_, value any) bool { + if value.(*Box).pool == pd.Name { + count++ + } + return true + }) + result = append(result, PoolInfo{ + Name: pd.Name, + Addr: pd.Addr, + Connected: connected, + Boxes: count, + MaxPerUser: pd.MaxPerUser, + MaxTotal: pd.MaxTotal, + IdleTimeout: pd.IdleTimeout, + MaxLifetime: pd.MaxLifetime, + }) + } + return result +} + +// Heartbeat updates the box's last heartbeat timestamp. +func (m *Manager) Heartbeat(sandboxID string, active bool, processCount int) error { + v, ok := m.boxes.Load(sandboxID) + if !ok { + return ErrNotFound + } + b := v.(*Box) + if active { + b.lastHeartbeat.Store(time.Now().UnixMilli()) + } + b.processCount.Store(int32(processCount)) + return nil +} + +// Create creates and starts a new sandbox. +func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error) { + if len(m.poolDefs) == 0 { + return nil, ErrNotAvailable + } + if opts.Image == "" { + return nil, fmt.Errorf("sandbox: image is required") + } + + poolName := opts.Pool + if poolName == "" { + poolName = m.defaultPool + } + + // Workspace node binding: when WorkspaceID is set, resolve the workspace's + // bound node and force the container onto that pool. + if opts.WorkspaceID != "" && m.wsManager != nil { + node, err := m.wsManager.NodeForWorkspace(ctx, opts.WorkspaceID) + if err != nil { + return nil, fmt.Errorf("sandbox: resolve workspace %q: %w", opts.WorkspaceID, err) + } + poolName = node + } + + pd := m.findPoolDef(poolName) + if pd == nil { + return nil, ErrPoolNotFound + } + + if err := m.checkLimits(pd, opts.Owner); err != nil { + return nil, err + } + + id := opts.ID + if id == "" { + id = fmt.Sprintf("sb-%d", time.Now().UnixNano()) + } + + client, err := m.getPool(poolName) + if err != nil { + return nil, fmt.Errorf("sandbox: connect pool %q: %w", poolName, err) + } + + access, refresh, err := CreateContainerTokens(id, opts.Owner, nil) + if err != nil { + return nil, fmt.Errorf("sandbox: create tokens: %w", err) + } + + taiOpts := m.buildTaiCreateOptions(opts, pd, id, access, refresh) + + containerID, err := client.Sandbox().Create(ctx, taiOpts) + if err != nil { + return nil, fmt.Errorf("sandbox: create container: %w", err) + } + + if err := client.Sandbox().Start(ctx, containerID); err != nil { + client.Sandbox().Remove(ctx, containerID, true) + return nil, fmt.Errorf("sandbox: start container: %w", err) + } + + policy := opts.Policy + if policy == "" { + policy = Session + } + + box := &Box{ + id: id, + containerID: containerID, + pool: poolName, + owner: opts.Owner, + 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()) + + m.boxes.Store(id, box) + return box, nil +} + +// Get returns an existing sandbox by ID. +func (m *Manager) Get(_ context.Context, id string) (*Box, error) { + v, ok := m.boxes.Load(id) + if !ok { + return nil, ErrNotFound + } + return v.(*Box), nil +} + +// GetOrCreate returns existing sandbox by ID or creates a new one. +func (m *Manager) GetOrCreate(ctx context.Context, opts CreateOptions) (*Box, error) { + if opts.ID != "" { + if v, ok := m.boxes.Load(opts.ID); ok { + return v.(*Box), nil + } + } + return m.Create(ctx, opts) +} + +// List returns all sandboxes, optionally filtered. +func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) { + var result []*Box + m.boxes.Range(func(_, value any) bool { + b := value.(*Box) + if opts.Owner != "" && b.owner != opts.Owner { + return true + } + if opts.Pool != "" && b.pool != opts.Pool { + return true + } + if len(opts.Labels) > 0 { + for k, v := range opts.Labels { + if b.labels[k] != v { + return true + } + } + } + result = append(result, b) + return true + }) + return result, nil +} + +// 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 { + return ErrNotFound + } + b := v.(*Box) + + client, err := m.getPool(b.pool) + if err == nil { + client.Sandbox().Remove(ctx, b.containerID, true) + } + + if b.refreshToken != "" { + RevokeContainerTokens(b.refreshToken) + } + + m.boxes.Delete(id) + return nil +} + +// Cleanup removes idle/expired sandboxes. +func (m *Manager) Cleanup(ctx context.Context) error { + now := time.Now() + m.boxes.Range(func(key, value any) bool { + b := value.(*Box) + idle := now.Sub(b.lastActiveTime()) + + switch b.policy { + case OneShot: + // handled after Exec + case Session: + if timeout := b.idleTimeout(); timeout > 0 && idle > timeout { + m.Remove(ctx, b.id) + } + 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, b.stopTimeout()) + } + } + if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime { + m.Remove(ctx, b.id) + } + case Persistent: + // never auto-cleaned + } + return true + }) + return nil +} + +// Close stops the cleanup loop and releases all pool connections. +func (m *Manager) Close() error { + if m.cancel != nil { + m.cancel() + } + m.mu.Lock() + defer m.mu.Unlock() + for name, client := range m.pool { + client.Close() + delete(m.pool, name) + } + return nil +} + +// SetGRPCPort sets the local gRPC port for container env injection. +func (m *Manager) SetGRPCPort(port int) { + m.grpcPort = port +} + +// SetWorkspaceManager links the workspace manager for workspace-aware container creation. +// When CreateOptions.WorkspaceID is set, the sandbox Manager uses the workspace Manager +// to resolve the workspace's bound node and force container routing. +func (m *Manager) SetWorkspaceManager(wm *workspace.Manager) { + m.wsManager = wm +} + +func (m *Manager) cleanupLoop(ctx context.Context) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + m.Cleanup(ctx) + case <-ctx.Done(): + return + } + } +} + +func (m *Manager) getPool(name string) (*tai.Client, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if client, ok := m.pool[name]; ok { + return client, nil + } + + pd := m.findPoolDefLocked(name) + if pd == nil { + return nil, ErrPoolNotFound + } + + client, err := tai.New(pd.Addr, pd.Options...) + if err != nil { + return nil, err + } + m.pool[name] = client + return client, nil +} + +func (m *Manager) findPoolDef(name string) *Pool { + m.mu.Lock() + defer m.mu.Unlock() + return m.findPoolDefLocked(name) +} + +func (m *Manager) findPoolDefLocked(name string) *Pool { + for i := range m.poolDefs { + if m.poolDefs[i].Name == name { + return &m.poolDefs[i] + } + } + return nil +} + +func (m *Manager) checkLimits(pd *Pool, owner string) error { + if pd.MaxTotal > 0 { + count := 0 + m.boxes.Range(func(_, value any) bool { + if value.(*Box).pool == pd.Name { + count++ + } + return true + }) + if count >= pd.MaxTotal { + return ErrLimitExceeded + } + } + + if pd.MaxPerUser > 0 && owner != "" { + count := 0 + m.boxes.Range(func(_, value any) bool { + b := value.(*Box) + if b.pool == pd.Name && b.owner == owner { + count++ + } + return true + }) + if count >= pd.MaxPerUser { + return ErrLimitExceeded + } + } + return nil +} + +func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID, access, refresh string) taisandbox.CreateOptions { + env := make(map[string]string) + for k, v := range opts.Env { + env[k] = v + } + grpcEnv := BuildGRPCEnv(pd, sandboxID, access, refresh, m.grpcPort) + for k, v := range grpcEnv { + env[k] = v + } + + labels := map[string]string{ + "managed-by": "yao-sandbox", + "sandbox-id": sandboxID, + "sandbox-owner": opts.Owner, + "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 + } + + workDir := opts.WorkDir + if workDir == "" { + workDir = "/workspace" + } + + cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"} + + var ports []taisandbox.PortMapping + for _, p := range opts.Ports { + ports = append(ports, taisandbox.PortMapping{ + ContainerPort: p.ContainerPort, + HostPort: p.HostPort, + HostIP: p.HostIP, + Protocol: p.Protocol, + }) + } + + // 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, + CPUs: opts.CPUs, + VNC: opts.VNC, + Ports: ports, + Labels: labels, + } +} + +func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client) { + containers, err := client.Sandbox().List(ctx, taisandbox.ListOptions{ + All: true, + Labels: map[string]string{"managed-by": "yao-sandbox"}, + }) + if err != nil { + return + } + + for _, c := range containers { + sandboxID := c.Labels["sandbox-id"] + if sandboxID == "" { + continue + } + if _, loaded := m.boxes.Load(sandboxID); loaded { + continue + } + + box := &Box{ + id: sandboxID, + containerID: c.ID, + pool: c.Labels["sandbox-pool"], + owner: c.Labels["sandbox-owner"], + policy: LifecyclePolicy(c.Labels["sandbox-policy"]), + 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 new file mode 100644 index 00000000..c42d049f --- /dev/null +++ b/sandbox/v2/manager_lifecycle_test.go @@ -0,0 +1,142 @@ +package sandbox_test + +import ( + "context" + "testing" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestHeartbeatUpdates(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) + + err := m.Heartbeat(box.ID(), true, 5) + if err != nil { + t.Fatalf("Heartbeat: %v", err) + } + + info, err := box.Info(context.Background()) + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.ProcessCount != 5 { + t.Errorf("ProcessCount = %d, want 5", info.ProcessCount) + } + }) + } +} + +func TestHeartbeatUnknownBox(t *testing.T) { + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + err := m.Heartbeat("nonexistent", true, 1) + if err != sandbox.ErrNotFound { + t.Errorf("err = %v, want ErrNotFound", err) + } + }) + } +} + +func TestIdleCleanup(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { + p.IdleTimeout = 1 * time.Second + }) + ensureTestImage(t, m, pc.Name) + + ctx := context.Background() + box, err := m.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "test-user", + Policy: sandbox.Session, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + boxID := box.ID() + + time.Sleep(2 * time.Second) + + if err := m.Cleanup(ctx); err != nil { + t.Fatalf("Cleanup: %v", err) + } + + _, err = m.Get(ctx, boxID) + if err != sandbox.ErrNotFound { + t.Errorf("after idle cleanup, Get err = %v, want ErrNotFound", err) + } + }) + } +} + +func TestStartRecovery(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr} + + m1 := setupManager(t, pool) + box := createTestBox(t, m1) + boxID := box.ID() + + cfg := sandbox.Config{Pool: []sandbox.Pool{pool}} + if err := sandbox.Init(cfg); err != nil { + t.Fatalf("Init2: %v", err) + } + m2 := sandbox.M() + defer m2.Close() + + ctx := context.Background() + if err := m2.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + recovered, err := m2.Get(ctx, boxID) + if err != nil { + t.Fatalf("Get recovered box: %v", err) + } + if recovered.Owner() != "test-user" { + t.Errorf("owner = %q, want %q", recovered.Owner(), "test-user") + } + + m2.Remove(ctx, boxID) + }) + } +} + +func TestPersistentNotCleaned(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { + p.IdleTimeout = 1 * time.Second + }) + + box := createTestBox(t, m, func(co *sandbox.CreateOptions) { + co.Policy = sandbox.Persistent + }) + + time.Sleep(2 * time.Second) + + ctx := context.Background() + m.Cleanup(ctx) + + _, err := m.Get(ctx, box.ID()) + if err != nil { + t.Errorf("persistent box should not be cleaned: %v", err) + } + }) + } +} diff --git a/sandbox/v2/manager_test.go b/sandbox/v2/manager_test.go new file mode 100644 index 00000000..a6e6be5f --- /dev/null +++ b/sandbox/v2/manager_test.go @@ -0,0 +1,299 @@ +package sandbox_test + +import ( + "context" + "testing" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestCreateAndExec(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) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result, err := box.Exec(ctx, []string{"echo", "hello"}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if result.ExitCode != 0 { + t.Errorf("exit code = %d, want 0", result.ExitCode) + } + if result.Stdout != "hello\n" { + t.Errorf("stdout = %q, want %q", result.Stdout, "hello\n") + } + }) + } +} + +func TestCreateWithLabels(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, func(co *sandbox.CreateOptions) { + co.Labels = map[string]string{"app": "test-app"} + }) + + ctx := context.Background() + info, err := box.Info(ctx) + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.Labels["app"] != "test-app" { + t.Errorf("label app = %q, want %q", info.Labels["app"], "test-app") + } + }) + } +} + +func TestGet(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) + + got, err := m.Get(context.Background(), box.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID() != box.ID() { + t.Errorf("ID = %q, want %q", got.ID(), box.ID()) + } + }) + } +} + +func TestGetNotFound(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") + if err != sandbox.ErrNotFound { + t.Errorf("err = %v, want ErrNotFound", err) + } + }) + } +} + +func TestList(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, func(co *sandbox.CreateOptions) { + co.Owner = "user-list" + }) + + boxes, err := m.List(context.Background(), sandbox.ListOptions{Owner: "user-list"}) + if err != nil { + t.Fatalf("List: %v", err) + } + found := false + for _, b := range boxes { + if b.ID() == box.ID() { + found = true + } + } + if !found { + t.Error("created box not found in list") + } + + empty, err := m.List(context.Background(), sandbox.ListOptions{Owner: "nobody"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(empty) != 0 { + t.Errorf("expected 0 results for unknown owner, got %d", len(empty)) + } + }) + } +} + +func TestRemove(t *testing.T) { + skipIfNoDocker(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(), + Owner: "test-user", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := m.Remove(ctx, box.ID()); err != nil { + t.Fatalf("Remove: %v", err) + } + + _, err = m.Get(ctx, box.ID()) + if err != sandbox.ErrNotFound { + t.Errorf("after Remove, Get err = %v, want ErrNotFound", err) + } + }) + } +} + +func TestPoolLimits_MaxTotal(t *testing.T) { + skipIfNoDocker(t) + + for _, pc := range testPools() { + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc, func(p *sandbox.Pool) { + p.MaxTotal = 1 + }) + ensureTestImage(t, m, pc.Name) + + box1 := createTestBox(t, m) + _ = box1 + + ctx := context.Background() + _, err := m.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "test-user", + }) + if err != sandbox.ErrLimitExceeded { + t.Errorf("second Create err = %v, want ErrLimitExceeded", err) + } + }) + } +} + +func TestAddPool(t *testing.T) { + m := setupManager(t, sandbox.Pool{ + Name: "default", + Addr: testLocalAddr(), + }) + + err := m.AddPool(context.Background(), sandbox.Pool{ + Name: "extra", + Addr: testLocalAddr(), + }) + if err != nil { + t.Fatalf("AddPool: %v", err) + } + + pools := m.Pools() + if len(pools) != 2 { + t.Fatalf("Pools() = %d, want 2", len(pools)) + } + + err = m.AddPool(context.Background(), sandbox.Pool{ + Name: "extra", + Addr: testLocalAddr(), + }) + if err == nil { + t.Error("expected error for duplicate pool name") + } +} + +func TestCreateNoImage(t *testing.T) { + m := setupManager(t, sandbox.Pool{ + Name: "local", + Addr: testLocalAddr(), + }) + + _, err := m.Create(context.Background(), sandbox.CreateOptions{ + Owner: "test", + }) + if err == nil { + t.Error("expected error for missing image") + } +} + +func TestCreateNoPools(t *testing.T) { + m := setupManager(t) + + _, err := m.Create(context.Background(), sandbox.CreateOptions{ + Image: testImage(), + }) + if err != sandbox.ErrNotAvailable { + t.Errorf("err = %v, want ErrNotAvailable", err) + } +} + +func TestMultiPool(t *testing.T) { + skipIfNoDocker(t) + skipIfNoTai(t) + + pools := testPools() + if len(pools) < 2 { + t.Skip("need at least 2 pools (local + remote) for multi-pool test") + } + + var sps []sandbox.Pool + for _, pc := range pools { + sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}) + } + m := setupManager(t, sps...) + + for _, pc := range pools { + ensureTestImage(t, m, pc.Name) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + localBox, err := m.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "test-user", + Pool: "local", + }) + if err != nil { + t.Fatalf("Create on local: %v", err) + } + defer m.Remove(ctx, localBox.ID()) + + remoteBox, err := m.Create(ctx, sandbox.CreateOptions{ + Image: testImage(), + Owner: "test-user", + Pool: "remote", + }) + if err != nil { + t.Fatalf("Create on remote: %v", err) + } + defer m.Remove(ctx, remoteBox.ID()) + + r1, err := localBox.Exec(ctx, []string{"echo", "local"}) + if err != nil { + t.Fatalf("Exec on local: %v", err) + } + if r1.Stdout != "local\n" { + t.Errorf("local stdout = %q, want %q", r1.Stdout, "local\n") + } + + r2, err := remoteBox.Exec(ctx, []string{"echo", "remote"}) + if err != nil { + t.Fatalf("Exec on remote: %v", err) + } + if r2.Stdout != "remote\n" { + t.Errorf("remote stdout = %q, want %q", r2.Stdout, "remote\n") + } + + localInfo, err := localBox.Info(ctx) + if err != nil { + t.Fatalf("Info local: %v", err) + } + remoteInfo, err := remoteBox.Info(ctx) + if err != nil { + t.Fatalf("Info remote: %v", err) + } + if localInfo.ID == remoteInfo.ID { + t.Error("local and remote boxes should have different IDs") + } +} diff --git a/sandbox/v2/sandbox.go b/sandbox/v2/sandbox.go new file mode 100644 index 00000000..46be58ea --- /dev/null +++ b/sandbox/v2/sandbox.go @@ -0,0 +1,23 @@ +package sandbox + +var mgr *Manager + +// Init initializes the global sandbox Manager. +// Config contains pool definitions. At least one Pool entry is required. +// Pass empty Pool list to disable sandbox (methods return ErrNotAvailable). +func Init(cfg Config) error { + m, err := newManager(cfg) + if err != nil { + return err + } + mgr = m + return nil +} + +// M returns the global Manager. Panics if Init was not called. +func M() *Manager { + if mgr == nil { + panic("sandbox.Init not called") + } + return mgr +} diff --git a/sandbox/v2/sandbox_test.go b/sandbox/v2/sandbox_test.go new file mode 100644 index 00000000..7ef408e5 --- /dev/null +++ b/sandbox/v2/sandbox_test.go @@ -0,0 +1,41 @@ +package sandbox_test + +import ( + "testing" + + sandbox "github.com/yaoapp/yao/sandbox/v2" +) + +func TestInit(t *testing.T) { + cfg := sandbox.Config{ + Pool: []sandbox.Pool{ + {Name: "test", Addr: "local"}, + }, + } + if err := sandbox.Init(cfg); err != nil { + t.Fatalf("Init: %v", err) + } + m := sandbox.M() + if m == nil { + t.Fatal("M() returned nil") + } + m.Close() +} + +func TestInitEmpty(t *testing.T) { + cfg := sandbox.Config{} + if err := sandbox.Init(cfg); err != nil { + t.Fatalf("Init with empty config: %v", err) + } + sandbox.M().Close() +} + +func TestMPanicWithoutInit(t *testing.T) { + sandbox.ResetForTest() + defer func() { + if r := recover(); r == nil { + t.Error("expected panic from M() without Init") + } + }() + sandbox.M() +} diff --git a/sandbox/v2/testutils_test.go b/sandbox/v2/testutils_test.go new file mode 100644 index 00000000..ff4b512d --- /dev/null +++ b/sandbox/v2/testutils_test.go @@ -0,0 +1,196 @@ +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 + Options []tai.Option +} + +// testPools returns all available pool configurations for multi-mode testing. +// - local: always present (direct Docker daemon) +// - remote: when SANDBOX_TEST_REMOTE_ADDR is set (Tai on host โ†’ Docker) +// - containerized: when TAI_TEST_CONTAINERIZED_HOST is set (Tai in container โ†’ Docker) +// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai โ†’ K8s) +func testPools() []poolConfig { + pools := []poolConfig{ + {Name: "local", Addr: testLocalAddr()}, + } + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + pools = append(pools, poolConfig{Name: "remote", Addr: addr}) + } + if host := os.Getenv("TAI_TEST_CONTAINERIZED_HOST"); host != "" { + grpcPort := envPort("TAI_TEST_CONTAINERIZED_GRPC_PORT", 9200) + addr := fmt.Sprintf("tai://%s:%d", host, grpcPort) + // No WithPorts for HTTP/VNC โ€” Tai self-inspects its container + // and returns host-mapped ports via ServerInfo automatically. + pools = append(pools, poolConfig{Name: "containerized", Addr: addr}) + } + if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { + kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG") + if kubeconfig == "" { + return pools + } + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) + addr := fmt.Sprintf("tai://%s:%d", host, grpcPort) + opts := []tai.Option{ + tai.K8s, + tai.WithKubeConfig(kubeconfig), + tai.WithPorts(tai.Ports{ + K8s: envPort("TAI_TEST_K8S_PORT", 6443), + GRPC: grpcPort, + }), + } + 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 +} + +func skipIfNoDocker(t *testing.T) { + t.Helper() + addr := testLocalAddr() + if addr == "" { + t.Skip("SANDBOX_TEST_LOCAL_ADDR not set, skipping Docker tests") + } +} + +func skipIfNoTai(t *testing.T) { + t.Helper() + if os.Getenv("SANDBOX_TEST_REMOTE_ADDR") == "" { + t.Skip("SANDBOX_TEST_REMOTE_ADDR not set, skipping Tai proxy tests") + } +} + +func testLocalAddr() string { + if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" { + return addr + } + return "local" +} + +func testImage() string { + if img := os.Getenv("SANDBOX_TEST_IMAGE"); img != "" { + return img + } + 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} + if err := sandbox.Init(cfg); err != nil { + t.Fatalf("Init: %v", err) + } + m := sandbox.M() + t.Cleanup(func() { + m.Close() + }) + return m +} + +func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager { + t.Helper() + pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options} + for _, fn := range mutators { + fn(&pool) + } + return setupManager(t, pool) +} + +// setupManagerWithWorkspace creates a sandbox Manager with a linked workspace Manager. +// Returns both managers and a helper to create workspaces on the given pool's node. +func setupManagerWithWorkspace(t *testing.T, pc poolConfig) (*sandbox.Manager, *workspace.Manager) { + 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{ + Image: testImage(), + Owner: "test-user", + } + for _, fn := range opts { + fn(&co) + } + + 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) + } + t.Cleanup(func() { + m.Remove(context.Background(), box.ID()) + }) + return box +} diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go new file mode 100644 index 00000000..4d72a0c4 --- /dev/null +++ b/sandbox/v2/types.go @@ -0,0 +1,178 @@ +package sandbox + +import ( + "io" + "time" + + "github.com/yaoapp/yao/tai" +) + +type LifecyclePolicy string + +const ( + OneShot LifecyclePolicy = "oneshot" + Session LifecyclePolicy = "session" + LongRunning LifecyclePolicy = "longrunning" + Persistent LifecyclePolicy = "persistent" +) + +const DefaultStopTimeout = 2 * time.Second + +type Pool struct { + Name string + Addr string + Options []tai.Option + MaxPerUser int + MaxTotal int + IdleTimeout time.Duration + MaxLifetime time.Duration + StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout +} + +type PoolInfo struct { + Name string + Addr string + Connected bool + Boxes int + MaxPerUser int + MaxTotal int + IdleTimeout time.Duration + MaxLifetime time.Duration +} + +type PortMapping struct { + ContainerPort int + HostPort int + HostIP string + Protocol string +} + +type CreateOptions struct { + ID string + Owner string + Labels map[string]string + Pool string + Image string + WorkDir string + User string + Env map[string]string + Memory int64 + CPUs float64 + VNC bool + 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 { + Owner string + Pool string + Labels map[string]string +} + +type execConfig struct { + WorkDir string + Env map[string]string + Timeout time.Duration +} + +type ExecOption func(*execConfig) + +func WithWorkDir(dir string) ExecOption { + return func(c *execConfig) { + c.WorkDir = dir + } +} + +func WithEnv(env map[string]string) ExecOption { + return func(c *execConfig) { + c.Env = env + } +} + +func WithTimeout(timeout time.Duration) ExecOption { + return func(c *execConfig) { + c.Timeout = timeout + } +} + +type ExecResult struct { + ExitCode int + Stdout string + Stderr string +} + +type ExecStream struct { + Stdout io.ReadCloser + Stderr io.ReadCloser + Stdin io.WriteCloser + Wait func() (int, error) + Cancel func() +} + +type attachConfig struct { + Protocol string + Path string + Headers map[string]string +} + +type AttachOption func(*attachConfig) + +func WithProtocol(protocol string) AttachOption { + return func(c *attachConfig) { + c.Protocol = protocol + } +} + +func WithPath(path string) AttachOption { + return func(c *attachConfig) { + c.Path = path + } +} + +func WithHeaders(headers map[string]string) AttachOption { + return func(c *attachConfig) { + c.Headers = headers + } +} + +// 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 + Events <-chan []byte + URL string + Close func() error +} + +type BoxInfo struct { + ID string + ContainerID string + Pool string + Owner string + Status string + Policy LifecyclePolicy + Labels map[string]string + Image string + CreatedAt time.Time + LastActive time.Time + ProcessCount int + VNC bool +} diff --git a/tai/docs/README.md b/tai/docs/README.md index 47848611..8048d7c8 100644 --- a/tai/docs/README.md +++ b/tai/docs/README.md @@ -18,8 +18,8 @@ Go client library for the [Tai](https://github.com/YaoApp/tai) runtime bridge. P ### Local Mode (direct Docker) ```go -c, err := tai.New("") -// or: tai.New("unix:///var/run/docker.sock") +c, err := tai.New("local") +// or: tai.New("docker:///var/run/docker.sock") // or: tai.New("tcp://192.168.1.50:2375") defer c.Close() diff --git a/tai/grpc/cmd/main.go b/tai/grpc/cmd/main.go index e7ab70b9..c05bb097 100644 --- a/tai/grpc/cmd/main.go +++ b/tai/grpc/cmd/main.go @@ -71,6 +71,10 @@ func serve() error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + if sandboxID := os.Getenv("YAO_SANDBOX_ID"); sandboxID != "" { + go yaogrpc.HeartbeatLoop(ctx, client, sandboxID) + } + scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 0, 4*1024*1024), 4*1024*1024) encoder := json.NewEncoder(os.Stdout) diff --git a/tai/grpc/grpc.go b/tai/grpc/grpc.go index 4f538bcd..5c914f3d 100644 --- a/tai/grpc/grpc.go +++ b/tai/grpc/grpc.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/yaoapp/yao/grpc/pb" "google.golang.org/grpc" @@ -36,6 +37,9 @@ func NewFromEnv() (*Client, error) { } // Dial connects to the gRPC server at addr with the given TokenManager. +// Bare host:port addresses are wrapped with passthrough:/// for grpc.NewClient +// compatibility (grpc.NewClient defaults to dns scheme which may fail for hostnames +// like host.docker.internal). func Dial(addr string, tm *TokenManager) (*Client, error) { opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -47,7 +51,12 @@ func Dial(addr string, tm *TokenManager) (*Client, error) { ) } - conn, err := grpc.NewClient(addr, opts...) + target := addr + if !strings.Contains(addr, "://") { + target = "passthrough:///" + addr + } + + conn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("dial %s: %w", addr, err) } @@ -228,6 +237,22 @@ func (c *Client) AgentStream(ctx context.Context, assistantID string, messages, } } +// --- Sandbox --- + +// Heartbeat sends a sandbox heartbeat to the Yao gRPC server. +func (c *Client) Heartbeat(ctx context.Context, sandboxID string, cpuPercent int32, memBytes int64, runningProcs int32) (string, error) { + resp, err := c.svc.Heartbeat(ctx, &pb.HeartbeatRequest{ + SandboxId: sandboxID, + CpuPercent: cpuPercent, + MemBytes: memBytes, + RunningProcs: runningProcs, + }) + if err != nil { + return "", err + } + return resp.Action, nil +} + // --- Health --- // Healthz checks the server health. diff --git a/tai/grpc/grpc_test.go b/tai/grpc/grpc_test.go index 47057843..e252b0dc 100644 --- a/tai/grpc/grpc_test.go +++ b/tai/grpc/grpc_test.go @@ -169,6 +169,41 @@ func TestDial_WithTokenManager(t *testing.T) { assert.False(t, c.TokenManager().IsTaiMode()) } +func TestDial_PassthroughPrefix_BareAddress(t *testing.T) { + c, err := yaogrpc.Dial("host.docker.internal:9099", nil) + require.NoError(t, err) + defer c.Close() + + assert.Equal(t, "passthrough:///host.docker.internal:9099", c.Conn().Target()) +} + +func TestDial_PassthroughPrefix_IPAddress(t *testing.T) { + c, err := yaogrpc.Dial("192.168.1.100:9100", nil) + require.NoError(t, err) + defer c.Close() + + assert.Equal(t, "passthrough:///192.168.1.100:9100", c.Conn().Target()) +} + +func TestDial_PassthroughPrefix_PreservesExistingScheme(t *testing.T) { + tests := []struct { + addr string + target string + }{ + {"dns:///myhost:9099", "dns:///myhost:9099"}, + {"passthrough:///127.0.0.1:9099", "passthrough:///127.0.0.1:9099"}, + {"unix:///var/run/grpc.sock", "unix:///var/run/grpc.sock"}, + } + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + c, err := yaogrpc.Dial(tt.addr, nil) + require.NoError(t, err) + defer c.Close() + assert.Equal(t, tt.target, c.Conn().Target()) + }) + } +} + func TestClient_Close_Nil(t *testing.T) { c := &yaogrpc.Client{} assert.NoError(t, c.Close()) diff --git a/tai/grpc/heartbeat.go b/tai/grpc/heartbeat.go new file mode 100644 index 00000000..7a42043c --- /dev/null +++ b/tai/grpc/heartbeat.go @@ -0,0 +1,77 @@ +package grpc + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +const defaultHeartbeatInterval = 10 * time.Second + +// HeartbeatLoop sends periodic heartbeats to the Yao gRPC server. +// It runs until ctx is cancelled. The sandboxID comes from YAO_SANDBOX_ID. +func HeartbeatLoop(ctx context.Context, client *Client, sandboxID string) { + interval := defaultHeartbeatInterval + if s := os.Getenv("YAO_HEARTBEAT_INTERVAL"); s != "" { + if d, err := time.ParseDuration(s); err == nil && d > 0 { + interval = d + } + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cpu, mem := sampleResources() + procs := countUserProcesses() + action, err := client.Heartbeat(ctx, sandboxID, cpu, mem, procs) + if err != nil { + continue + } + if action == "shutdown" { + fmt.Fprintf(os.Stderr, "yao-grpc: received shutdown signal\n") + p, _ := os.FindProcess(os.Getpid()) + p.Signal(os.Interrupt) + return + } + } + } +} + +// countUserProcesses counts running processes owned by the current user. +func countUserProcesses() int32 { + if runtime.GOOS != "linux" { + return 0 + } + + out, err := exec.Command("sh", "-c", "ps -e --no-headers | wc -l").Output() + if err != nil { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return int32(n) +} + +// sampleResources reads basic CPU/memory stats from /proc (Linux only). +func sampleResources() (cpuPercent int32, memBytes int64) { + if runtime.GOOS != "linux" { + return 0, 0 + } + + data, err := os.ReadFile("/sys/fs/cgroup/memory.current") + if err == nil { + mem, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + memBytes = mem + } + + return 0, memBytes +} diff --git a/tai/grpc/heartbeat_test.go b/tai/grpc/heartbeat_test.go new file mode 100644 index 00000000..a7db6bd5 --- /dev/null +++ b/tai/grpc/heartbeat_test.go @@ -0,0 +1,194 @@ +package grpc + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/yaoapp/yao/grpc/pb" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func TestCountUserProcesses(t *testing.T) { + n := countUserProcesses() + if n < 0 { + t.Errorf("countUserProcesses() = %d, want >= 0", n) + } +} + +func TestSampleResources(t *testing.T) { + cpu, mem := sampleResources() + if cpu < 0 || mem < 0 { + t.Errorf("sampleResources() = (%d, %d), want non-negative", cpu, mem) + } +} + +// โ”€โ”€ HeartbeatLoop tests with mock gRPC server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +type mockYaoServer struct { + pb.UnimplementedYaoServer + calls atomic.Int32 + action string +} + +func (m *mockYaoServer) Heartbeat(_ context.Context, req *pb.HeartbeatRequest) (*pb.HeartbeatResponse, error) { + m.calls.Add(1) + return &pb.HeartbeatResponse{Action: m.action}, nil +} + +func startMockServer(t *testing.T, srv *mockYaoServer) (addr string, stop func()) { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s := grpc.NewServer() + pb.RegisterYaoServer(s, srv) + go s.Serve(lis) + return lis.Addr().String(), s.Stop +} + +func dialClient(t *testing.T, addr string) *Client { + t.Helper() + c, err := Dial(addr, nil) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestHeartbeatLoop_SendsHeartbeats(t *testing.T) { + mock := &mockYaoServer{action: "ok"} + addr, stop := startMockServer(t, mock) + defer stop() + + client := dialClient(t, addr) + defer client.Close() + + t.Setenv("YAO_HEARTBEAT_INTERVAL", "50ms") + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + HeartbeatLoop(ctx, client, "sb-test") + + calls := mock.calls.Load() + if calls < 2 { + t.Errorf("expected at least 2 heartbeat calls, got %d", calls) + } +} + +func TestHeartbeatLoop_ShutdownAction(t *testing.T) { + mock := &mockYaoServer{action: "shutdown"} + addr, stop := startMockServer(t, mock) + defer stop() + + client := dialClient(t, addr) + defer client.Close() + + action, err := client.Heartbeat(context.Background(), "sb-shutdown", 0, 0, 0) + if err != nil { + t.Fatalf("Heartbeat: %v", err) + } + if action != "shutdown" { + t.Errorf("action = %q, want %q", action, "shutdown") + } + if mock.calls.Load() != 1 { + t.Errorf("expected 1 call, got %d", mock.calls.Load()) + } +} + +func TestHeartbeatLoop_ContextCancelStops(t *testing.T) { + mock := &mockYaoServer{action: "ok"} + addr, stop := startMockServer(t, mock) + defer stop() + + client := dialClient(t, addr) + defer client.Close() + + t.Setenv("YAO_HEARTBEAT_INTERVAL", "5s") + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + HeartbeatLoop(ctx, client, "sb-cancel") + close(done) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("HeartbeatLoop did not stop after context cancel") + } +} + +func TestHeartbeatLoop_IntervalParsing(t *testing.T) { + mock := &mockYaoServer{action: "ok"} + addr, stop := startMockServer(t, mock) + defer stop() + + client := dialClient(t, addr) + defer client.Close() + + t.Setenv("YAO_HEARTBEAT_INTERVAL", "30ms") + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + HeartbeatLoop(ctx, client, "sb-interval") + + calls := mock.calls.Load() + if calls < 3 { + t.Errorf("with 30ms interval over 150ms, expected >= 3 calls, got %d", calls) + } +} + +func TestHeartbeatLoop_InvalidIntervalUsesDefault(t *testing.T) { + mock := &mockYaoServer{action: "ok"} + addr, stop := startMockServer(t, mock) + defer stop() + + client := dialClient(t, addr) + defer client.Close() + + t.Setenv("YAO_HEARTBEAT_INTERVAL", "not-a-duration") + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + HeartbeatLoop(ctx, client, "sb-invalid") + + if mock.calls.Load() > 0 { + t.Error("with default 10s interval and 100ms timeout, expected 0 calls") + } +} + +func TestClientHeartbeat_ReturnsAction(t *testing.T) { + mock := &mockYaoServer{action: "ok"} + addr, stop := startMockServer(t, mock) + defer stop() + + conn, err := grpc.NewClient("passthrough:///"+addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + c := &Client{conn: conn, svc: pb.NewYaoClient(conn)} + action, err := c.Heartbeat(context.Background(), "sb-1", 50, 2048, 5) + if err != nil { + t.Fatalf("Heartbeat: %v", err) + } + if action != "ok" { + t.Errorf("action = %q, want %q", action, "ok") + } +} diff --git a/tai/proxy/connect.go b/tai/proxy/connect.go new file mode 100644 index 00000000..ecd9f043 --- /dev/null +++ b/tai/proxy/connect.go @@ -0,0 +1,117 @@ +package proxy + +import ( + "bufio" + "bytes" + "context" + "fmt" + "net/http" + "strings" + + "github.com/gorilla/websocket" +) + +// --- Remote Connect --- + +func (r *remoteProxy) Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) { + baseURL, err := r.URL(ctx, containerID, opts.Port, opts.Path) + if err != nil { + return nil, err + } + return connect(ctx, baseURL, opts.Protocol, r.client) +} + +// --- Local Connect --- + +func (l *localProxy) Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) { + baseURL, err := l.URL(ctx, containerID, opts.Port, opts.Path) + if err != nil { + return nil, err + } + return connect(ctx, baseURL, opts.Protocol, http.DefaultClient) +} + +func connect(ctx context.Context, url string, protocol string, hc *http.Client) (*Connection, error) { + switch protocol { + case "ws": + return connectWS(ctx, url) + case "sse": + return connectSSE(ctx, url, hc) + default: + return nil, fmt.Errorf("unsupported connect protocol: %q", protocol) + } +} + +func connectWS(ctx context.Context, rawURL string) (*Connection, error) { + wsURL := strings.Replace(rawURL, "http://", "ws://", 1) + wsURL = strings.Replace(wsURL, "https://", "wss://", 1) + + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) + if err != nil { + return nil, fmt.Errorf("ws dial: %w", err) + } + + ch := make(chan []byte, 64) + go func() { + defer close(ch) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + ch <- msg + } + }() + + return &Connection{ + Messages: ch, + Send: func(data []byte) error { + return conn.WriteMessage(websocket.TextMessage, data) + }, + Close: func() error { + return conn.Close() + }, + }, nil +} + +func connectSSE(ctx context.Context, url string, hc *http.Client) (*Connection, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "text/event-stream") + + resp, err := hc.Do(req) + if err != nil { + return nil, fmt.Errorf("sse connect: %w", err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("sse: status %d", resp.StatusCode) + } + + ch := make(chan []byte, 64) + go func() { + defer close(ch) + defer resp.Body.Close() + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + ch <- bytes.Clone([]byte(data)) + } + } + }() + + return &Connection{ + Messages: ch, + Send: func(data []byte) error { + return fmt.Errorf("sse: send not supported") + }, + Close: func() error { + resp.Body.Close() + return nil + }, + }, nil +} diff --git a/tai/proxy/proxy.go b/tai/proxy/proxy.go index d74ee8a5..c79ca5d4 100644 --- a/tai/proxy/proxy.go +++ b/tai/proxy/proxy.go @@ -13,9 +13,27 @@ import ( // Remote routes through Tai HTTP proxy; Local resolves host ports directly. type Proxy interface { URL(ctx context.Context, containerID string, port int, path string) (string, error) + Connect(ctx context.Context, containerID string, opts ConnectOptions) (*Connection, error) Healthz(ctx context.Context) error } +// ConnectOptions configures a persistent connection to a container service. +type ConnectOptions struct { + Port int // container port + Path string // URL path (e.g. "/ws" or "/events") + Protocol string // "ws", "sse", or "tcp" +} + +// Connection represents a persistent connection to a container service. +type Connection struct { + // Messages receives incoming data. Channel is closed when the connection ends. + Messages <-chan []byte + // Send writes data to the connection (only valid for "ws" protocol). + Send func(data []byte) error + // Close terminates the connection. + Close func() error +} + // --- Remote implementation --- type remoteProxy struct { diff --git a/tai/proxy/proxy_test.go b/tai/proxy/proxy_test.go index e988897c..5e5aaa1d 100644 --- a/tai/proxy/proxy_test.go +++ b/tai/proxy/proxy_test.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" + "github.com/gorilla/websocket" "github.com/yaoapp/yao/tai/sandbox" ) @@ -138,6 +140,126 @@ func TestHostIP(t *testing.T) { } } +// โ”€โ”€ Connect tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +func TestConnect_UnsupportedProtocol(t *testing.T) { + _, err := connect(context.Background(), "http://127.0.0.1:1234", "tcp", http.DefaultClient) + if err == nil { + t.Fatal("expected error for unsupported protocol") + } + if !strings.Contains(err.Error(), "unsupported connect protocol") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestConnectWS_EchoRoundtrip(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + for { + mt, msg, err := c.ReadMessage() + if err != nil { + return + } + c.WriteMessage(mt, msg) + } + })) + defer srv.Close() + + conn, err := connectWS(context.Background(), srv.URL) + if err != nil { + t.Fatalf("connectWS: %v", err) + } + defer conn.Close() + + if err := conn.Send([]byte("hello")); err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case msg := <-conn.Messages: + if string(msg) != "hello" { + t.Errorf("got %q, want %q", msg, "hello") + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for echo") + } +} + +func TestConnectSSE_ReceiveEvents(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + for i := 0; i < 3; i++ { + fmt.Fprintf(w, "data: event-%d\n\n", i) + flusher.Flush() + } + })) + defer srv.Close() + + conn, err := connectSSE(context.Background(), srv.URL, srv.Client()) + if err != nil { + t.Fatalf("connectSSE: %v", err) + } + defer conn.Close() + + var events []string + for msg := range conn.Messages { + events = append(events, string(msg)) + if len(events) >= 3 { + break + } + } + if len(events) != 3 { + t.Fatalf("got %d events, want 3", len(events)) + } + for i, e := range events { + want := fmt.Sprintf("event-%d", i) + if e != want { + t.Errorf("event[%d] = %q, want %q", i, e, want) + } + } +} + +func TestConnectSSE_SendNotSupported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "data: x\n\n") + })) + defer srv.Close() + + conn, err := connectSSE(context.Background(), srv.URL, srv.Client()) + if err != nil { + t.Fatalf("connectSSE: %v", err) + } + defer conn.Close() + + if err := conn.Send([]byte("test")); err == nil { + t.Error("expected error from SSE Send") + } +} + +func TestConnectSSE_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + _, err := connectSSE(context.Background(), srv.URL, srv.Client()) + if err == nil { + t.Fatal("expected error for non-200") + } + if !strings.Contains(err.Error(), "status 503") { + t.Errorf("unexpected error: %v", err) + } +} + // mockSandbox implements sandbox.Sandbox for testing. type mockSandbox struct { inspectFn func(ctx context.Context, id string) (*sandbox.ContainerInfo, error) @@ -154,6 +276,9 @@ func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error { func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) { return nil, nil } +func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.StreamHandle, error) { + return nil, nil +} func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { if m.inspectFn != nil { return m.inspectFn(ctx, id) 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/docker.go b/tai/sandbox/docker.go index 93fd7c6f..7ae908b1 100644 --- a/tai/sandbox/docker.go +++ b/tai/sandbox/docker.go @@ -32,7 +32,7 @@ func NewDocker(addr string) (Sandbox, error) { } func (d *dockerSandbox) Create(ctx context.Context, opts CreateOptions) (string, error) { - return d.core.create(ctx, opts, false) + return d.core.create(ctx, opts, true) } func (d *dockerSandbox) Start(ctx context.Context, id string) error { @@ -51,6 +51,10 @@ func (d *dockerSandbox) Exec(ctx context.Context, id string, cmd []string, opts return d.core.exec(ctx, id, cmd, opts) } +func (d *dockerSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) { + return d.core.execStream(ctx, id, cmd, opts) +} + func (d *dockerSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) { return d.core.inspect(ctx, id) } diff --git a/tai/sandbox/docker_core.go b/tai/sandbox/docker_core.go index beeb8893..a0e77581 100644 --- a/tai/sandbox/docker_core.go +++ b/tai/sandbox/docker_core.go @@ -26,6 +26,8 @@ func (d *dockerCore) create(ctx context.Context, opts CreateOptions, addVNCPorts Cmd: opts.Cmd, Env: envSlice(opts.Env), WorkingDir: opts.WorkingDir, + Labels: opts.Labels, + User: opts.User, } hostCfg := &container.HostConfig{ @@ -129,6 +131,68 @@ func (d *dockerCore) exec(ctx context.Context, id string, cmd []string, opts Exe }, nil } +func (d *dockerCore) execStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) { + execCfg := container.ExecOptions{ + Cmd: cmd, + WorkingDir: opts.WorkDir, + Env: envSlice(opts.Env), + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + } + + execResp, err := d.cli.ContainerExecCreate(ctx, id, execCfg) + if err != nil { + return nil, fmt.Errorf("exec create: %w", err) + } + + resp, err := d.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) + if err != nil { + return nil, fmt.Errorf("exec attach: %w", err) + } + + execCtx, execCancel := context.WithCancel(ctx) + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + stderrR, stderrW := io.Pipe() + + // Pump user writes into the multiplexed connection. + // Closing stdinW sends EOF to the container stdin without + // tearing down the underlying connection (which carries stdout/stderr). + go func() { + io.Copy(resp.Conn, stdinR) + resp.CloseWrite() + }() + + go func() { + _, _ = stdcopy.StdCopy(stdoutW, stderrW, resp.Reader) + stdoutW.Close() + stderrW.Close() + }() + + return &StreamHandle{ + Stdin: stdinW, + Stdout: stdoutR, + Stderr: stderrR, + Wait: func() (int, error) { + for { + inspect, err := d.cli.ContainerExecInspect(execCtx, execResp.ID) + if err != nil { + return -1, fmt.Errorf("exec inspect: %w", err) + } + if !inspect.Running { + return inspect.ExitCode, nil + } + } + }, + Cancel: func() { + execCancel() + resp.Close() + }, + }, nil +} + func (d *dockerCore) inspect(ctx context.Context, id string) (*ContainerInfo, error) { info, err := d.cli.ContainerInspect(ctx, id) if err != nil { @@ -140,6 +204,7 @@ func (d *dockerCore) inspect(ctx context.Context, id string) (*ContainerInfo, er Name: strings.TrimPrefix(info.Name, "/"), Image: info.Config.Image, Status: info.State.Status, + Labels: info.Config.Labels, } if info.NetworkSettings != nil { @@ -196,6 +261,7 @@ func (d *dockerCore) list(ctx context.Context, opts ListOptions) ([]ContainerInf Name: name, Image: c.Image, Status: c.State, + Labels: c.Labels, } for _, p := range c.Ports { ci.Ports = append(ci.Ports, PortMapping{ 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 bf93bdb1..9ea5c826 100644 --- a/tai/sandbox/k8s.go +++ b/tai/sandbox/k8s.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "path/filepath" "strings" "time" @@ -112,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, } @@ -126,6 +127,9 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er labels[k] = v } labels["sandbox-name"] = name + for k, v := range opts.Labels { + labels[k] = v + } pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -139,6 +143,15 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er }, } + if opts.User != "" { + uid, err := parseUID(opts.User) + if err == nil { + pod.Spec.SecurityContext = &corev1.PodSecurityContext{ + RunAsUser: &uid, + } + } + } + created, err := s.cli.CoreV1().Pods(s.ns).Create(ctx, pod, metav1.CreateOptions{}) if err != nil { return "", fmt.Errorf("create pod: %w", err) @@ -147,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) @@ -157,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 { @@ -236,6 +260,78 @@ func (s *k8sSandbox) Exec(ctx context.Context, id string, cmd []string, opts Exe }, nil } +func (s *k8sSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) { + execCmd := cmd + if opts.WorkDir != "" || len(opts.Env) > 0 { + var prefix string + for k, v := range opts.Env { + prefix += fmt.Sprintf("export %s=%q; ", k, v) + } + cdPart := "" + if opts.WorkDir != "" { + cdPart = fmt.Sprintf("cd %s && ", opts.WorkDir) + } + execCmd = []string{"sh", "-c", cdPart + prefix + strings.Join(cmd, " ")} + } + + req := s.cli.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(id). + Namespace(s.ns). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: "main", + Command: execCmd, + Stdin: true, + Stdout: true, + Stderr: true, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(s.cfg, "POST", req.URL()) + if err != nil { + return nil, fmt.Errorf("create executor: %w", err) + } + + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + stderrR, stderrW := io.Pipe() + + execCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + var exitCode int + + go func() { + err := exec.StreamWithContext(execCtx, remotecommand.StreamOptions{ + Stdin: stdinR, + Stdout: stdoutW, + Stderr: stderrW, + }) + if err != nil { + if exitErr, ok := err.(interface{ ExitStatus() int }); ok { + exitCode = exitErr.ExitStatus() + err = nil + } + } + stdoutW.Close() + stderrW.Close() + done <- err + }() + + return &StreamHandle{ + Stdin: stdinW, + Stdout: stdoutR, + Stderr: stderrR, + Wait: func() (int, error) { + err := <-done + return exitCode, err + }, + Cancel: func() { + cancel() + stdinR.Close() + }, + }, nil +} + func (s *k8sSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) { pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{}) if err != nil { @@ -248,6 +344,7 @@ func (s *k8sSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, er Image: pod.Spec.Containers[0].Image, Status: string(pod.Status.Phase), IP: pod.Status.PodIP, + Labels: pod.Labels, }, nil } @@ -273,6 +370,7 @@ func (s *k8sSandbox) List(ctx context.Context, opts ListOptions) ([]ContainerInf Name: pod.Name, Status: string(pod.Status.Phase), IP: pod.Status.PodIP, + Labels: pod.Labels, } if len(pod.Spec.Containers) > 0 { ci.Image = pod.Spec.Containers[0].Image @@ -286,6 +384,14 @@ func (s *k8sSandbox) Close() error { return nil // REST client doesn't need explicit close } +// parseUID extracts a numeric UID from a user string like "1000" or "1000:1000". +func parseUID(user string) (int64, error) { + parts := strings.SplitN(user, ":", 2) + var uid int64 + _, err := fmt.Sscanf(parts[0], "%d", &uid) + return uid, err +} + func buildResources(memory int64, cpus float64) corev1.ResourceRequirements { limits := corev1.ResourceList{} if memory > 0 { diff --git a/tai/sandbox/local.go b/tai/sandbox/local.go index e9c5a709..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 { @@ -55,6 +54,10 @@ func (l *local) Exec(ctx context.Context, id string, cmd []string, opts ExecOpti return l.core.exec(ctx, id, cmd, opts) } +func (l *local) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) { + return l.core.execStream(ctx, id, cmd, opts) +} + func (l *local) Inspect(ctx context.Context, id string) (*ContainerInfo, error) { return l.core.inspect(ctx, id) } @@ -67,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/sandbox/sandbox.go b/tai/sandbox/sandbox.go index 368ddcf0..31f854aa 100644 --- a/tai/sandbox/sandbox.go +++ b/tai/sandbox/sandbox.go @@ -2,6 +2,7 @@ package sandbox import ( "context" + "io" "time" ) @@ -13,11 +14,23 @@ type Sandbox interface { Stop(ctx context.Context, id string, timeout time.Duration) error Remove(ctx context.Context, id string, force bool) error Exec(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecResult, error) + ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) Inspect(ctx context.Context, id string) (*ContainerInfo, error) List(ctx context.Context, opts ListOptions) ([]ContainerInfo, error) Close() error } +// StreamHandle provides real-time I/O access to a running exec process. +type StreamHandle struct { + Stdin io.WriteCloser + Stdout io.Reader + Stderr io.Reader + // Wait blocks until the exec process finishes and returns the exit code. + Wait func() (int, error) + // Cancel aborts the exec process. + Cancel func() +} + // CreateOptions configures a new container. type CreateOptions struct { Name string @@ -30,6 +43,8 @@ type CreateOptions struct { CPUs float64 // 0 = no limit VNC bool Ports []PortMapping + Labels map[string]string // container/pod labels for discovery and management + User string // container user, e.g. "1000:1000" or "sandbox" } // PortMapping maps a container port to a host port. @@ -48,6 +63,7 @@ type ContainerInfo struct { Status string // "created", "running", "exited", "removing" IP string Ports []PortMapping + Labels map[string]string } // ExecOptions configures a command execution inside a container. diff --git a/tai/sandbox/sandbox_test.go b/tai/sandbox/sandbox_test.go index 061ff982..e061b557 100644 --- a/tai/sandbox/sandbox_test.go +++ b/tai/sandbox/sandbox_test.go @@ -2,7 +2,9 @@ package sandbox import ( "context" + "io" "os" + "strings" "testing" "time" @@ -625,3 +627,332 @@ func TestNewK8sRelativeKubeConfig(t *testing.T) { t.Skipf("K8s not available: %v", err) } } + +func TestCreateWithLabels(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + labels := map[string]string{ + "sandbox-id": "test-123", + "sandbox-owner": "user1", + } + + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-label-test", + Image: "alpine:latest", + Cmd: []string{"sleep", "10"}, + Labels: labels, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + info, err := sb.Inspect(ctx, id) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + for k, v := range labels { + if info.Labels[k] != v { + t.Errorf("label %q = %q, want %q", k, info.Labels[k], v) + } + } + + listed, err := sb.List(ctx, ListOptions{ + Labels: map[string]string{"sandbox-id": "test-123"}, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + found := false + for _, c := range listed { + if c.ID == id { + found = true + if c.Labels["sandbox-owner"] != "user1" { + t.Errorf("list labels missing sandbox-owner") + } + } + } + if !found { + t.Error("labeled container not found in filtered list") + } +} + +func TestCreateWithUser(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-user-test", + Image: "alpine:latest", + Cmd: []string{"sleep", "10"}, + User: "1000:1000", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + result, err := sb.Exec(ctx, id, []string{"id", "-u"}, ExecOptions{}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if result.Stdout != "1000\n" { + t.Errorf("user id = %q, want %q", result.Stdout, "1000\n") + } +} + +func TestExecStream_ShortCommand(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-stream-short", + Image: "alpine:latest", + Cmd: []string{"sleep", "30"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + stream, err := sb.ExecStream(ctx, id, []string{"echo", "hello-stream"}, ExecOptions{}) + if err != nil { + t.Fatalf("ExecStream: %v", err) + } + + out, err := io.ReadAll(stream.Stdout) + if err != nil { + t.Fatalf("ReadAll stdout: %v", err) + } + if string(out) != "hello-stream\n" { + t.Errorf("stdout = %q, want %q", string(out), "hello-stream\n") + } + + code, err := stream.Wait() + if err != nil { + t.Fatalf("Wait: %v", err) + } + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } +} + +func TestExecStream_Stdin(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-stream-stdin", + Image: "alpine:latest", + Cmd: []string{"sleep", "30"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + stream, err := sb.ExecStream(ctx, id, []string{"cat"}, ExecOptions{}) + if err != nil { + t.Fatalf("ExecStream: %v", err) + } + + _, err = stream.Stdin.Write([]byte("from-stdin\n")) + if err != nil { + t.Fatalf("Write stdin: %v", err) + } + stream.Stdin.Close() + + out, err := io.ReadAll(stream.Stdout) + if err != nil { + t.Fatalf("ReadAll stdout: %v", err) + } + if string(out) != "from-stdin\n" { + t.Errorf("stdout = %q, want %q", string(out), "from-stdin\n") + } + + code, err := stream.Wait() + if err != nil { + t.Fatalf("Wait: %v", err) + } + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } +} + +func TestExecStream_ExitCode(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-stream-exit", + Image: "alpine:latest", + Cmd: []string{"sleep", "30"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + stream, err := sb.ExecStream(ctx, id, []string{"sh", "-c", "exit 42"}, ExecOptions{}) + if err != nil { + t.Fatalf("ExecStream: %v", err) + } + + io.ReadAll(stream.Stdout) + code, err := stream.Wait() + if err != nil { + t.Fatalf("Wait: %v", err) + } + if code != 42 { + t.Errorf("exit code = %d, want 42", code) + } +} + +func TestExecStream_Stderr(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-stream-stderr", + Image: "alpine:latest", + Cmd: []string{"sleep", "30"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + stream, err := sb.ExecStream(ctx, id, []string{"sh", "-c", "echo err-msg >&2"}, ExecOptions{}) + if err != nil { + t.Fatalf("ExecStream: %v", err) + } + + stderr, err := io.ReadAll(stream.Stderr) + if err != nil { + t.Fatalf("ReadAll stderr: %v", err) + } + if !strings.Contains(string(stderr), "err-msg") { + t.Errorf("stderr = %q, want to contain %q", string(stderr), "err-msg") + } + + code, _ := stream.Wait() + if code != 0 { + t.Errorf("exit code = %d, want 0", code) + } +} + +func TestExecStream_Cancel(t *testing.T) { + sb, err := NewLocal("") + if err != nil { + t.Skipf("Docker not available: %v", err) + } + defer sb.Close() + + ctx := context.Background() + id, err := sb.Create(ctx, CreateOptions{ + Name: "tai-stream-cancel", + Image: "alpine:latest", + Cmd: []string{"sleep", "30"}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + defer sb.Remove(ctx, id, true) + + if err := sb.Start(ctx, id); err != nil { + t.Fatalf("Start: %v", err) + } + + stream, err := sb.ExecStream(ctx, id, []string{"sleep", "300"}, ExecOptions{}) + if err != nil { + t.Fatalf("ExecStream: %v", err) + } + + stream.Cancel() + + done := make(chan struct{}) + go func() { + stream.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("Wait did not return after Cancel within 5s") + } +} + +func TestParseUID(t *testing.T) { + tests := []struct { + input string + want int64 + ok bool + }{ + {"1000", 1000, true}, + {"1000:1000", 1000, true}, + {"0", 0, true}, + {"abc", 0, false}, + } + for _, tt := range tests { + got, err := parseUID(tt.input) + if tt.ok && err != nil { + t.Errorf("parseUID(%q): unexpected error %v", tt.input, err) + } + if !tt.ok && err == nil { + t.Errorf("parseUID(%q): expected error", tt.input) + } + if tt.ok && got != tt.want { + t.Errorf("parseUID(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} diff --git a/tai/serverinfo/pb/serverinfo.pb.go b/tai/serverinfo/pb/serverinfo.pb.go new file mode 100644 index 00000000..5d85f88a --- /dev/null +++ b/tai/serverinfo/pb/serverinfo.pb.go @@ -0,0 +1,195 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v4.25.0 +// source: tai/serverinfo/pb/serverinfo.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoRequest) Reset() { + *x = GetInfoRequest{} + mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoRequest) ProtoMessage() {} + +func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. +func (*GetInfoRequest) Descriptor() ([]byte, []int) { + return file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP(), []int{0} +} + +type GetInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + Ports map[string]int32 `protobuf:"bytes,2,rep,name=ports,proto3" json:"ports,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // "grpc", "http", "vnc", "docker", "k8s" + Capabilities map[string]bool `protobuf:"bytes,3,rep,name=capabilities,proto3" json:"capabilities,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` // "docker", "k8s" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInfoResponse) Reset() { + *x = GetInfoResponse{} + mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInfoResponse) ProtoMessage() {} + +func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_tai_serverinfo_pb_serverinfo_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. +func (*GetInfoResponse) Descriptor() ([]byte, []int) { + return file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP(), []int{1} +} + +func (x *GetInfoResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *GetInfoResponse) GetPorts() map[string]int32 { + if x != nil { + return x.Ports + } + return nil +} + +func (x *GetInfoResponse) GetCapabilities() map[string]bool { + if x != nil { + return x.Capabilities + } + return nil +} + +var File_tai_serverinfo_pb_serverinfo_proto protoreflect.FileDescriptor + +const file_tai_serverinfo_pb_serverinfo_proto_rawDesc = "" + + "\n" + + "\"tai/serverinfo/pb/serverinfo.proto\x12\n" + + "serverinfo\"\x10\n" + + "\x0eGetInfoRequest\"\xb7\x02\n" + + "\x0fGetInfoResponse\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12<\n" + + "\x05ports\x18\x02 \x03(\v2&.serverinfo.GetInfoResponse.PortsEntryR\x05ports\x12Q\n" + + "\fcapabilities\x18\x03 \x03(\v2-.serverinfo.GetInfoResponse.CapabilitiesEntryR\fcapabilities\x1a8\n" + + "\n" + + "PortsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\x1a?\n" + + "\x11CapabilitiesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\bR\x05value:\x028\x012P\n" + + "\n" + + "ServerInfo\x12B\n" + + "\aGetInfo\x12\x1a.serverinfo.GetInfoRequest\x1a\x1b.serverinfo.GetInfoResponseB%Z#github.com/yaoapp/tai/serverinfo/pbb\x06proto3" + +var ( + file_tai_serverinfo_pb_serverinfo_proto_rawDescOnce sync.Once + file_tai_serverinfo_pb_serverinfo_proto_rawDescData []byte +) + +func file_tai_serverinfo_pb_serverinfo_proto_rawDescGZIP() []byte { + file_tai_serverinfo_pb_serverinfo_proto_rawDescOnce.Do(func() { + file_tai_serverinfo_pb_serverinfo_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_tai_serverinfo_pb_serverinfo_proto_rawDesc), len(file_tai_serverinfo_pb_serverinfo_proto_rawDesc))) + }) + return file_tai_serverinfo_pb_serverinfo_proto_rawDescData +} + +var file_tai_serverinfo_pb_serverinfo_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_tai_serverinfo_pb_serverinfo_proto_goTypes = []any{ + (*GetInfoRequest)(nil), // 0: serverinfo.GetInfoRequest + (*GetInfoResponse)(nil), // 1: serverinfo.GetInfoResponse + nil, // 2: serverinfo.GetInfoResponse.PortsEntry + nil, // 3: serverinfo.GetInfoResponse.CapabilitiesEntry +} +var file_tai_serverinfo_pb_serverinfo_proto_depIdxs = []int32{ + 2, // 0: serverinfo.GetInfoResponse.ports:type_name -> serverinfo.GetInfoResponse.PortsEntry + 3, // 1: serverinfo.GetInfoResponse.capabilities:type_name -> serverinfo.GetInfoResponse.CapabilitiesEntry + 0, // 2: serverinfo.ServerInfo.GetInfo:input_type -> serverinfo.GetInfoRequest + 1, // 3: serverinfo.ServerInfo.GetInfo:output_type -> serverinfo.GetInfoResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_tai_serverinfo_pb_serverinfo_proto_init() } +func file_tai_serverinfo_pb_serverinfo_proto_init() { + if File_tai_serverinfo_pb_serverinfo_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_tai_serverinfo_pb_serverinfo_proto_rawDesc), len(file_tai_serverinfo_pb_serverinfo_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_tai_serverinfo_pb_serverinfo_proto_goTypes, + DependencyIndexes: file_tai_serverinfo_pb_serverinfo_proto_depIdxs, + MessageInfos: file_tai_serverinfo_pb_serverinfo_proto_msgTypes, + }.Build() + File_tai_serverinfo_pb_serverinfo_proto = out.File + file_tai_serverinfo_pb_serverinfo_proto_goTypes = nil + file_tai_serverinfo_pb_serverinfo_proto_depIdxs = nil +} diff --git a/tai/serverinfo/pb/serverinfo.proto b/tai/serverinfo/pb/serverinfo.proto new file mode 100644 index 00000000..4eece2ee --- /dev/null +++ b/tai/serverinfo/pb/serverinfo.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; +package serverinfo; +option go_package = "github.com/yaoapp/tai/serverinfo/pb"; + +service ServerInfo { + rpc GetInfo(GetInfoRequest) returns (GetInfoResponse); +} + +message GetInfoRequest {} + +message GetInfoResponse { + string version = 1; + map ports = 2; // "grpc", "http", "vnc", "docker", "k8s" + map capabilities = 3; // "docker", "k8s" +} diff --git a/tai/serverinfo/pb/serverinfo_grpc.pb.go b/tai/serverinfo/pb/serverinfo_grpc.pb.go new file mode 100644 index 00000000..07e690ec --- /dev/null +++ b/tai/serverinfo/pb/serverinfo_grpc.pb.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v4.25.0 +// source: tai/serverinfo/pb/serverinfo.proto + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ServerInfo_GetInfo_FullMethodName = "/serverinfo.ServerInfo/GetInfo" +) + +// ServerInfoClient is the client API for ServerInfo service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ServerInfoClient interface { + GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) +} + +type serverInfoClient struct { + cc grpc.ClientConnInterface +} + +func NewServerInfoClient(cc grpc.ClientConnInterface) ServerInfoClient { + return &serverInfoClient{cc} +} + +func (c *serverInfoClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInfoResponse) + err := c.cc.Invoke(ctx, ServerInfo_GetInfo_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ServerInfoServer is the server API for ServerInfo service. +// All implementations must embed UnimplementedServerInfoServer +// for forward compatibility. +type ServerInfoServer interface { + GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) + mustEmbedUnimplementedServerInfoServer() +} + +// UnimplementedServerInfoServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedServerInfoServer struct{} + +func (UnimplementedServerInfoServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented") +} +func (UnimplementedServerInfoServer) mustEmbedUnimplementedServerInfoServer() {} +func (UnimplementedServerInfoServer) testEmbeddedByValue() {} + +// UnsafeServerInfoServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ServerInfoServer will +// result in compilation errors. +type UnsafeServerInfoServer interface { + mustEmbedUnimplementedServerInfoServer() +} + +func RegisterServerInfoServer(s grpc.ServiceRegistrar, srv ServerInfoServer) { + // If the following call panics, it indicates UnimplementedServerInfoServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ServerInfo_ServiceDesc, srv) +} + +func _ServerInfo_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInfoRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ServerInfoServer).GetInfo(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ServerInfo_GetInfo_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ServerInfoServer).GetInfo(ctx, req.(*GetInfoRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ServerInfo_ServiceDesc is the grpc.ServiceDesc for ServerInfo service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ServerInfo_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "serverinfo.ServerInfo", + HandlerType: (*ServerInfoServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetInfo", + Handler: _ServerInfo_GetInfo_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "tai/serverinfo/pb/serverinfo.proto", +} diff --git a/tai/tai.go b/tai/tai.go index 8c9ae33c..1fc52ec0 100644 --- a/tai/tai.go +++ b/tai/tai.go @@ -1,13 +1,17 @@ package tai import ( + "context" "fmt" "net/http" "net/url" + "strconv" "strings" + "time" "github.com/yaoapp/yao/tai/proxy" "github.com/yaoapp/yao/tai/sandbox" + sipb "github.com/yaoapp/yao/tai/serverinfo/pb" "github.com/yaoapp/yao/tai/vnc" "github.com/yaoapp/yao/tai/volume" "github.com/yaoapp/yao/tai/workspace" @@ -44,8 +48,12 @@ type Ports struct { } // WithPorts overrides default Tai service ports. +// Ports set here take precedence over server-reported values from ServerInfo. func WithPorts(p Ports) Option { - return optionFunc(func(c *config) { c.ports = p }) + return optionFunc(func(c *config) { + c.ports = p + c.userPorts = p + }) } // WithHTTPClient sets a custom HTTP client for proxy and VNC health checks. @@ -69,13 +77,21 @@ 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 + userPorts Ports // tracks explicitly set ports (zero = not set by user) httpClient *http.Client dataDir string kubeConfig string namespace string + volume volume.Volume // override volume (for testing without Docker) } func defaultPorts() Ports { @@ -112,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 @@ -121,9 +139,11 @@ type Client struct { // New creates a Client based on the address protocol: // -// "" โ†’ Local mode, platform default Docker socket +// "local" โ†’ Local mode, platform default Docker socket // "docker://addr" โ†’ Local mode, specified Docker daemon // "tai://host" โ†’ Remote mode via Tai Server +// +// Empty string is not allowed โ€” use "local" for default local Docker. func New(addr string, opts ...Option) (*Client, error) { cfg := &config{ports: defaultPorts()} for _, o := range opts { @@ -131,11 +151,15 @@ func New(addr string, opts ...Option) (*Client, error) { } cfg.ports = mergedPorts(cfg.ports) - scheme, host, dockerAddr, err := parseAddr(addr) + scheme, host, dockerAddr, grpcPort, err := parseAddr(addr) if err != nil { return nil, err } + if grpcPort > 0 { + cfg.ports.GRPC = grpcPort + } + c := &Client{ scheme: scheme, host: host, @@ -155,32 +179,48 @@ 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 } func (c *Client) initRemote(cfg *config) (*Client, error) { - // gRPC connection grpcAddr := fmt.Sprintf("%s:%d", c.host, c.ports.GRPC) conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err) } c.grpcConn = conn + + // Auto-discover server ports via ServerInfo RPC. + // Only overwrite ports that were NOT explicitly set by WithPorts. + if err := c.discoverPorts(conn, cfg); err != nil { + // Non-fatal: fall back to defaults / WithPorts values. + // Old Tai servers without ServerInfo will hit this path. + _ = err + } + c.vol = volume.NewRemote(conn) - // Sandbox switch cfg.runtime { case K8s: k8sPort := c.ports.K8s @@ -197,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 { @@ -209,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 @@ -244,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) @@ -252,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 } @@ -261,41 +310,100 @@ func (c *Client) VNC() vnc.VNC { return c.vc } // IsLocal returns true if the client connects directly to a Docker daemon. func (c *Client) IsLocal() bool { return c.scheme == "docker" } -func parseAddr(addr string) (scheme, host, dockerAddr string, err error) { +func parseAddr(addr string) (scheme, host, dockerAddr string, grpcPort int, err error) { addr = strings.TrimSpace(addr) if addr == "" { - return "docker", "", "", nil + return "", "", "", 0, fmt.Errorf("empty address: use \"local\" for default Docker daemon") + } + + if addr == "local" { + return "docker", "", "", 0, nil + } + + // Bare IP or host(:port) without scheme โ†’ normalise before url.Parse, + // which misparses bare addresses (treats them as path, not host). + if !strings.Contains(addr, "://") { + if isLocalHost(addr) { + return "docker", "", "", 0, nil + } + // host:port โ€” split carefully (IPv6 like [::1]:9100 is already handled above) + h := addr + if idx := strings.LastIndex(addr, ":"); idx > 0 { + h = addr[:idx] + } + if isLocalHost(h) { + return "docker", "", "", 0, nil + } + addr = "tai://" + addr } u, parseErr := url.Parse(addr) if parseErr != nil { - return "", "", "", fmt.Errorf("parse addr %q: %w", addr, parseErr) + return "", "", "", 0, fmt.Errorf("parse addr %q: %w", addr, parseErr) } switch u.Scheme { case "tai": - host = u.Host - if host == "" { - return "", "", "", fmt.Errorf("tai:// requires a host") + hostname := u.Hostname() + if hostname == "" { + return "", "", "", 0, fmt.Errorf("tai:// requires a host") } - if idx := strings.Index(host, ":"); idx >= 0 { - host = host[:idx] + if portStr := u.Port(); portStr != "" { + if p, convErr := strconv.Atoi(portStr); convErr == nil && p > 0 { + grpcPort = p + } } - return "tai", host, "", nil + return "tai", hostname, "", grpcPort, nil case "docker": - return "docker", "", addr, nil + return "docker", "", addr, 0, nil case "unix": - return "docker", "", addr, nil + return "docker", "", addr, 0, nil case "tcp": - return "docker", "", addr, nil + return "docker", "", addr, 0, nil case "npipe": - return "docker", "", addr, nil + return "docker", "", addr, 0, nil default: - return "", "", "", fmt.Errorf("unsupported scheme %q in addr %q", u.Scheme, addr) + return "", "", "", 0, fmt.Errorf("unsupported scheme %q in addr %q", u.Scheme, addr) } } + +func isLocalHost(h string) bool { + return h == "127.0.0.1" || h == "localhost" || h == "::1" +} + +// discoverPorts calls ServerInfo.GetInfo on the remote Tai server and merges +// discovered ports into c.ports. Ports explicitly set via WithPorts (non-zero +// in the original config before merging defaults) take precedence. +func (c *Client) discoverPorts(conn *grpc.ClientConn, cfg *config) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client := sipb.NewServerInfoClient(conn) + resp, err := client.GetInfo(ctx, &sipb.GetInfoRequest{}) + if err != nil { + return err + } + + // cfg.userPorts tracks what the caller explicitly passed to WithPorts. + // Only overwrite ports that the caller did NOT explicitly set. + up := cfg.userPorts + + if p := int(resp.Ports["http"]); p > 0 && up.HTTP == 0 { + c.ports.HTTP = p + } + if p := int(resp.Ports["docker"]); p > 0 && up.Docker == 0 { + c.ports.Docker = p + } + if p := int(resp.Ports["vnc"]); p > 0 && up.VNC == 0 { + c.ports.VNC = p + } + if p := int(resp.Ports["k8s"]); p > 0 && up.K8s == 0 { + c.ports.K8s = p + } + return nil +} diff --git a/tai/tai_test.go b/tai/tai_test.go index 92d2350d..d00838e6 100644 --- a/tai/tai_test.go +++ b/tai/tai_test.go @@ -1,6 +1,7 @@ package tai import ( + "fmt" "os" "strconv" "testing" @@ -24,28 +25,38 @@ func envPort(key string, fallback int) int { func TestParseAddr(t *testing.T) { tests := []struct { - addr string - wantScheme string - wantHost string - wantDocker string - wantErr bool + addr string + wantScheme string + wantHost string + wantDocker string + wantGRPCPort int + wantErr bool }{ - {"", "docker", "", "", false}, - {"docker:///var/run/docker.sock", "docker", "", "docker:///var/run/docker.sock", false}, - {"docker://192.168.1.50:2375", "docker", "", "docker://192.168.1.50:2375", false}, - {"unix:///var/run/docker.sock", "docker", "", "unix:///var/run/docker.sock", false}, - {"tcp://127.0.0.1:2375", "docker", "", "tcp://127.0.0.1:2375", false}, - {"npipe:////./pipe/docker_engine", "docker", "", "npipe:////./pipe/docker_engine", false}, - {"tai://192.168.1.100", "tai", "192.168.1.100", "", false}, - {"tai://10.0.0.5:9100", "tai", "10.0.0.5", "", false}, - {"tai://", "", "", "", true}, - {"ftp://host", "", "", "", true}, - {" tai://host ", "tai", "host", "", false}, + {"", "", "", "", 0, true}, + {"local", "docker", "", "", 0, false}, + {"127.0.0.1", "docker", "", "", 0, false}, + {"localhost", "docker", "", "", 0, false}, + {"::1", "docker", "", "", 0, false}, + {"docker:///var/run/docker.sock", "docker", "", "docker:///var/run/docker.sock", 0, false}, + {"docker://192.168.1.50:2375", "docker", "", "docker://192.168.1.50:2375", 0, false}, + {"unix:///var/run/docker.sock", "docker", "", "unix:///var/run/docker.sock", 0, false}, + {"tcp://127.0.0.1:2375", "docker", "", "tcp://127.0.0.1:2375", 0, false}, + {"npipe:////./pipe/docker_engine", "docker", "", "npipe:////./pipe/docker_engine", 0, false}, + {"tai://192.168.1.100", "tai", "192.168.1.100", "", 0, false}, + {"tai://10.0.0.5:9200", "tai", "10.0.0.5", "", 9200, false}, + {"tai://", "", "", "", 0, true}, + {"ftp://host", "", "", "", 0, true}, + {" tai://host ", "tai", "host", "", 0, false}, + // Bare non-local host โ†’ auto-prepend tai:// + {"192.168.1.50", "tai", "192.168.1.50", "", 0, false}, + {"192.168.1.50:9200", "tai", "192.168.1.50", "", 9200, false}, + {"my-server", "tai", "my-server", "", 0, false}, + {"my-server:9200", "tai", "my-server", "", 9200, false}, } for _, tt := range tests { t.Run(tt.addr, func(t *testing.T) { - scheme, host, dockerAddr, err := parseAddr(tt.addr) + scheme, host, dockerAddr, grpcPort, err := parseAddr(tt.addr) if (err != nil) != tt.wantErr { t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) } @@ -61,6 +72,9 @@ func TestParseAddr(t *testing.T) { if dockerAddr != tt.wantDocker { t.Errorf("dockerAddr = %q, want %q", dockerAddr, tt.wantDocker) } + if grpcPort != tt.wantGRPCPort { + t.Errorf("grpcPort = %d, want %d", grpcPort, tt.wantGRPCPort) + } }) } } @@ -98,6 +112,9 @@ func TestOptions(t *testing.T) { if cfg.ports.HTTP != 9999 { t.Errorf("WithPorts: HTTP = %d", cfg.ports.HTTP) } + if cfg.userPorts.HTTP != 9999 { + t.Errorf("WithPorts: userPorts.HTTP = %d", cfg.userPorts.HTTP) + } WithDataDir("/data").apply(cfg) if cfg.dataDir != "/data" { @@ -116,8 +133,15 @@ func TestOptions(t *testing.T) { } } +func TestNewEmptyAddr(t *testing.T) { + _, err := New("") + if err == nil { + t.Error("expected error for empty addr") + } +} + func TestNewLocal(t *testing.T) { - c, err := New("") + c, err := New("local") if err != nil { t.Skipf("Docker not available: %v", err) } @@ -148,7 +172,7 @@ func TestNewLocal(t *testing.T) { func TestNewLocalWithDataDir(t *testing.T) { dir := t.TempDir() - c, err := New("", WithDataDir(dir)) + c, err := New("local", WithDataDir(dir)) if err != nil { t.Skipf("Docker not available: %v", err) } @@ -178,14 +202,15 @@ func TestNewRemoteK8s(t *testing.T) { t.Skip("TAI_TEST_K8S_HOST or TAI_TEST_KUBECONFIG not set") } + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) ports := Ports{ K8s: envPort("TAI_TEST_K8S_PORT", 6443), - GRPC: envPort("TAI_TEST_GRPC_PORT", 9100), - HTTP: envPort("TAI_TEST_HTTP_PORT", 8080), - VNC: envPort("TAI_TEST_VNC_PORT", 6080), + GRPC: grpcPort, + HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8080), + VNC: envPort("TAI_TEST_K8S_VNC_PORT", 6080), } - c, err := New("tai://"+host, K8s, + c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s, WithPorts(ports), WithKubeConfig(kubeconfig), WithNamespace("default"), @@ -258,11 +283,39 @@ func TestNewRemoteDocker(t *testing.T) { } } -func TestNewRemoteWithPorts(t *testing.T) { +func TestDiscoverPorts(t *testing.T) { addr := "tai://" + taiTestHost() - c, err := New(addr, WithPorts(Ports{HTTP: 8888})) + c, err := New(addr) if err != nil { t.Skipf("Tai not available at %s: %v", addr, err) } defer c.Close() + + t.Logf("client resolved: GRPC=%d HTTP=%d VNC=%d Docker=%d K8s=%d", + c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker, c.ports.K8s) + + if c.ports.GRPC == 0 { + t.Error("GRPC port should be discovered (non-zero)") + } + if c.ports.HTTP == 0 { + t.Error("HTTP port should be discovered (non-zero)") + } +} + +func TestDiscoverPortsWithUserOverride(t *testing.T) { + addr := "tai://" + taiTestHost() + c, err := New(addr, WithPorts(Ports{HTTP: 9999})) + if err != nil { + t.Skipf("Tai not available at %s: %v", addr, err) + } + defer c.Close() + + if c.ports.HTTP != 9999 { + t.Errorf("HTTP = %d, want 9999 (user override should take precedence)", c.ports.HTTP) + } + if c.ports.GRPC == 0 { + t.Error("GRPC port should still be discovered (non-zero)") + } + t.Logf("ports: GRPC=%d HTTP=%d(user) VNC=%d Docker=%d", + c.ports.GRPC, c.ports.HTTP, c.ports.VNC, c.ports.Docker) } diff --git a/tai/vnc/vnc_test.go b/tai/vnc/vnc_test.go index af6e41a2..36914e6f 100644 --- a/tai/vnc/vnc_test.go +++ b/tai/vnc/vnc_test.go @@ -202,6 +202,9 @@ func (m *mockSandbox) Remove(ctx context.Context, id string, force bool) error { func (m *mockSandbox) Exec(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.ExecResult, error) { return nil, nil } +func (m *mockSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts sandbox.ExecOptions) (*sandbox.StreamHandle, error) { + return nil, nil +} func (m *mockSandbox) Inspect(ctx context.Context, id string) (*sandbox.ContainerInfo, error) { if m.inspectFn != nil { return m.inspectFn(ctx, id) 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/jsapi/fs.go b/workspace/jsapi/fs.go new file mode 100644 index 00000000..17c0515c --- /dev/null +++ b/workspace/jsapi/fs.go @@ -0,0 +1,130 @@ +package jsapi + +import ( + "rogchap.com/v8go" +) + +// NewFSObject creates a JS WorkspaceFS object backed by a workspace ID string. +// All methods delegate to workspace.M() โ†’ FS โ€” no Go object passed to V8. +// +// # Properties (read-only) +// +// ws.id โ†’ string // workspace ID โ† workspaceID arg +// ws.name โ†’ string // workspace name โ† Workspace.Name +// ws.node โ†’ string // tai node name โ† Workspace.Node +// +// # Methods โ€” Go mapping +// +// Each method internally does: fs, _ := workspace.M().FS(ctx, workspaceID) +// then calls the corresponding method on taiworkspace.FS. +// +// ws.ReadFile(path) โ†’ string +// +// Go: FS.ReadFile(name string) ([]byte, error) +// โ€” also available via Manager.ReadFile(ctx, id, path) +// JS args: path string +// JS returns: string (UTF-8 content of the file) +// +// ws.WriteFile(path, data, perm?) โ†’ void +// +// Go: FS.WriteFile(name string, data []byte, perm os.FileMode) error +// โ€” also available via Manager.WriteFile(ctx, id, path, data, perm) +// JS args: path string, data string|Uint8Array, perm? number (default 0644) +// +// ws.ReadDir(path?) โ†’ DirEntry[] +// +// Go: FS.ReadDir(name string) ([]fs.DirEntry, error) +// โ€” also available via Manager.ListDir(ctx, id, path) +// JS args: path string (default ".") +// JS returns: [{ +// name: string, โ† DirEntry.Name() +// is_dir: boolean, โ† DirEntry.IsDir() +// size: number โ† DirEntry.Info().Size() +// }] +// +// ws.Stat(path) โ†’ FileInfo +// +// Go: FS.Stat(name string) (fs.FileInfo, error) +// JS args: path string +// JS returns: { +// name: string, โ† FileInfo.Name() +// size: number, โ† FileInfo.Size() +// is_dir: boolean, โ† FileInfo.IsDir() +// mod_time: string โ† FileInfo.ModTime() (ISO 8601) +// } +// +// ws.MkdirAll(path, perm?) โ†’ void +// +// Go: FS.MkdirAll(name string, perm os.FileMode) error +// JS args: path string, perm? number (default 0755) +// +// ws.Remove(path) โ†’ void +// +// Go: FS.Remove(name string) error +// โ€” also available via Manager.Remove(ctx, id, path) +// JS args: path string (single file or empty directory) +// +// ws.RemoveAll(path) โ†’ void +// +// Go: FS.RemoveAll(name string) error +// JS args: path string (recursive removal) +// +// ws.Rename(from, to) โ†’ void +// +// Go: FS.Rename(oldname, newname string) error +// JS args: from string, to string +// +// # Base64 variants (PLANNED โ€” not yet implemented) +// +// Avoids V8โ†”Go binary bridge overhead for images, archives, etc. +// +// ws.ReadFileBase64(path) โ†’ string +// +// Go: FS.ReadFile(name) โ†’ base64.StdEncoding.EncodeToString(data) +// JS args: path string +// JS returns: string (base64-encoded content) +// +// ws.WriteFileBase64(path, b64, perm?) โ†’ void +// +// Go: base64.StdEncoding.DecodeString(b64) โ†’ FS.WriteFile(name, data, perm) +// JS args: path string, b64 string, perm? number (default 0644) +// +// # Host copy (PLANNED โ€” not yet implemented) +// +// Copy files/dirs from Yao host filesystem into the workspace volume. +// Useful for seeding workspaces with templates, config files, assets, etc. +// +// ws.CopyFromHost(hostPath, destPath?) โ†’ void +// +// Copies a single file or directory tree from the Yao host into the workspace. +// Go: read host file(s) โ†’ FS.WriteFile / FS.MkdirAll for each entry +// JS args: hostPath string (absolute path on Yao host), +// destPath? string (target path inside workspace, default basename of hostPath) +// +// ws.CopyFromHostArchive(hostPath, destPath?) โ†’ void +// +// For large directory trees: zip on host โ†’ transfer โ†’ unzip on Tai node. +// Requires Tai server-side unarchive support. +// Go: zip hostPath โ†’ tai Volume upload โ†’ tai unarchive at destPath +// JS args: hostPath string, destPath? string (default ".") +func NewFSObject(v8ctx *v8go.Context, workspaceID string) (*v8go.Value, error) { + // TODO: Phase 2 implementation + // 1. Create JS object via v8go.NewObjectTemplate + // 2. Set read-only properties: id, name, node (from workspace.M().Get(workspaceID)) + // 3. Bind each method as FunctionTemplate: + // - ReadFile โ†’ workspace.M().FS(ctx, id).ReadFile(path) + // - WriteFile โ†’ workspace.M().FS(ctx, id).WriteFile(path, data, perm) + // - ReadDir โ†’ workspace.M().FS(ctx, id).ReadDir(path) + // - Stat โ†’ workspace.M().FS(ctx, id).Stat(path) + // - MkdirAll โ†’ workspace.M().FS(ctx, id).MkdirAll(path, perm) + // - Remove โ†’ workspace.M().FS(ctx, id).Remove(path) + // - RemoveAll โ†’ workspace.M().FS(ctx, id).RemoveAll(path) + // - Rename โ†’ workspace.M().FS(ctx, id).Rename(old, new) + // + // PLANNED (not yet implemented): + // - ReadFileBase64 โ†’ ReadFile + base64 encode in Go + // - WriteFileBase64 โ†’ base64 decode in Go + WriteFile + // - CopyFromHost โ†’ host fs.Read โ†’ FS.Write (file-by-file) + // - CopyFromHostArchive โ†’ zip on host โ†’ tai transfer โ†’ unzip (needs Tai support) + return nil, nil +} diff --git a/workspace/jsapi/jsapi.go b/workspace/jsapi/jsapi.go new file mode 100644 index 00000000..624818da --- /dev/null +++ b/workspace/jsapi/jsapi.go @@ -0,0 +1,129 @@ +// Package jsapi registers the workspace namespace into the Yao V8 runtime. +// +// All methods are static on the workspace object โ€” no constructor. +// +// # JavaScript API +// +// const ws = workspace.Create({ name: "proj", owner: "user1", node: "default" }) +// const ws = workspace.Get(id) +// ws.ReadFile("main.go") โ†’ string +// ws.WriteFile("out.txt", data) โ†’ void +// ws.ReadDir("src/") โ†’ [{ name, is_dir, size }] +// workspace.Delete(id) โ†’ void +// +// # Go mapping +// +// workspace.Create(opts) โ†’ Manager.Create(ctx, CreateOptions) โ†’ *Workspace โ†’ WorkspaceFS +// workspace.Get(id) โ†’ Manager.Get(ctx, id) โ†’ *Workspace โ†’ WorkspaceFS +// workspace.List(filter?) โ†’ Manager.List(ctx, ListOptions) โ†’ []*Workspace โ†’ WorkspaceInfo[] +// workspace.Delete(id) โ†’ Manager.Delete(ctx, id, false) โ†’ void +// +// Registration happens via init() โ€” import with: +// +// _ "github.com/yaoapp/yao/workspace/jsapi" +package jsapi + +import ( + v8 "github.com/yaoapp/gou/runtime/v8" + "rogchap.com/v8go" +) + +func init() { + v8.RegisterObject("workspace", ExportObject) +} + +// ExportObject exports the workspace namespace object to V8. +func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate { + obj := v8go.NewObjectTemplate(iso) + obj.Set("Create", v8go.NewFunctionTemplate(iso, wsCreate)) + obj.Set("Get", v8go.NewFunctionTemplate(iso, wsGet)) + obj.Set("List", v8go.NewFunctionTemplate(iso, wsList)) + obj.Set("Delete", v8go.NewFunctionTemplate(iso, wsDelete)) + return obj +} + +// wsCreate: `workspace.Create(options)` โ†’ WorkspaceFS +// +// Go: Manager.Create(ctx, CreateOptions) (*Workspace, error) +// +// JS options โ†’ Go CreateOptions mapping: +// +// { +// id: string โ†’ CreateOptions.ID // optional; auto-generated if empty +// name: string โ†’ CreateOptions.Name // required, human-readable name +// owner: string โ†’ CreateOptions.Owner // required, user ID +// node: string โ†’ CreateOptions.Node // required, target Tai node +// labels: object โ†’ CreateOptions.Labels // optional, map[string]string +// } +// +// Returns: WorkspaceFS object (see fs.go) +func wsCreate(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse options from info.Args()[0] + // 2. Validate required fields (name, owner, node) + // 3. ws := workspace.M().Create(ctx, opts) + // 4. Return NewFSObject(v8ctx, ws.ID) + return v8go.Undefined(info.Context().Isolate()) +} + +// wsGet: `workspace.Get(id)` โ†’ WorkspaceFS | null +// +// Go: Manager.Get(ctx, id) (*Workspace, error) +// +// Args: +// +// id: string โ€” workspace ID +// +// Returns: WorkspaceFS object if found, null if not found +func wsGet(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. ws, err := workspace.M().Get(ctx, id) + // 3. Return NewFSObject(v8ctx, id) or null + return v8go.Undefined(info.Context().Isolate()) +} + +// wsList: `workspace.List(filter?)` โ†’ WorkspaceInfo[] +// +// Go: Manager.List(ctx, ListOptions) ([]*Workspace, error) +// +// JS filter โ†’ Go ListOptions mapping: +// +// { +// owner: string โ†’ ListOptions.Owner // filter by owner; empty = all +// node: string โ†’ ListOptions.Node // filter by node; empty = all +// } +// +// Returns: WorkspaceInfo[] โ€” each element: +// +// { +// id: string โ† Workspace.ID +// name: string โ† Workspace.Name +// owner: string โ† Workspace.Owner +// node: string โ† Workspace.Node +// labels: object โ† Workspace.Labels +// created_at: string โ† Workspace.CreatedAt (ISO 8601) +// updated_at: string โ† Workspace.UpdatedAt (ISO 8601) +// } +func wsList(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. Parse optional filter from info.Args()[0] + // 2. list := workspace.M().List(ctx, opts) + // 3. Convert each *Workspace โ†’ JS object + // 4. Return JS array + return v8go.Undefined(info.Context().Isolate()) +} + +// wsDelete: `workspace.Delete(id)` โ†’ void +// +// Go: Manager.Delete(ctx, id string, force bool) error +// +// Args: +// +// id: string โ€” workspace ID to remove (force = false) +func wsDelete(info *v8go.FunctionCallbackInfo) *v8go.Value { + // TODO: Phase 2 + // 1. id = info.Args()[0].String() + // 2. workspace.M().Delete(ctx, id, false) + return v8go.Undefined(info.Context().Isolate()) +} 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) +}