diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 1286244f..d4e872e9 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -924,12 +924,11 @@ jobs: }); # ============================================================================= - # Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d) - # TEMPORARILY DISABLED: docker images need rebuild after tai repo migration + # Sandbox V2 Tests (tai SDK + workspace, Docker + K8s via k3d) + # Full sandbox/v2 integration tests are run locally. # ============================================================================= SandboxV2Test: runs-on: ubuntu-latest - if: false # temporarily disabled – restore after sandbox docker images are rebuilt services: mongodb: image: mongo:6.0 @@ -984,7 +983,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, - body: '🤖 Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...' + body: '🤖 Sandbox V2 CI Tests running (tai + workspace)...' }); - name: Checkout Kun @@ -1164,7 +1163,7 @@ jobs: echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 } - - name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace) + - name: Run Sandbox V2 CI Tests (tai + workspace) env: TAI_TEST_HOST: "127.0.0.1" TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" @@ -1196,7 +1195,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: issue_number, - body: '✅ Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!' + body: '✅ Sandbox V2 CI Tests passed (tai + workspace)!' }); # ============================================================================= @@ -1808,227 +1807,6 @@ jobs: body: '✅ Registry Client SDK Tests passed!' }); - # ============================================================================= - # Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking) - # TEMPORARILY DISABLED: docker images need rebuild after tai repo migration - # ============================================================================= - BenchmarkSandboxV2: - runs-on: ubuntu-latest - if: false # temporarily disabled – restore after sandbox docker images are rebuilt - strategy: - matrix: - go: ["1.25"] - 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: 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/tai-sandbox-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 (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-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-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 - - 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 Benchmarks - 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/tai-sandbox-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 7b643904..519f655f 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -680,7 +680,8 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} # ============================================================================= - # Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d) + # Sandbox V2 Tests (tai SDK + workspace, Docker + K8s via k3d) + # Full sandbox/v2 integration tests are run locally. # ============================================================================= sandbox-v2-test: runs-on: ubuntu-latest @@ -873,7 +874,7 @@ jobs: echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 } - - name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace) + - name: Run Sandbox V2 CI Tests (tai + workspace) env: TAI_TEST_HOST: "127.0.0.1" TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" @@ -1349,194 +1350,6 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} - # ============================================================================= - # Benchmark: Sandbox V2 + Workspace (parallel with sandbox-v2-test) - # ============================================================================= - benchmark-sandbox-v2: - runs-on: ubuntu-latest - 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/tai-sandbox-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 (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-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-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 - - 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 Benchmarks - 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 benchmark-sandbox-v2 - # ============================================================================= # gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed) # ============================================================================= diff --git a/Makefile b/Makefile index a2978c87..7844d513 100644 --- a/Makefile +++ b/Makefile @@ -202,35 +202,17 @@ unit-test-registry: fi # --------------------------------------------------------------------------- -# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace) -# Requires: Docker, Tai container, optionally k3d for K8s mode +# Sandbox V2 CI Test (tai SDK + workspace only) +# Full sandbox/v2 integration tests (multi-pool, K8s, etc.) are run locally. # --------------------------------------------------------------------------- -SANDBOX_V2_IMAGE ?= yaoapp/tai-sandbox-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 +unit-test-sandbox-v2: unit-test-tai unit-test-workspace @echo "" @echo "=============================================" - @echo "All Sandbox V2 integration tests passed" + @echo "All Sandbox V2 CI tests passed (tai + workspace)" @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: @@ -271,26 +253,6 @@ unit-test-workspace: @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: diff --git a/grpc/IMPL.md b/grpc/IMPL.md index c880697e..459dcfb4 100644 --- a/grpc/IMPL.md +++ b/grpc/IMPL.md @@ -164,18 +164,18 @@ Deliverable: LLM (unary + stream) and Agent streaming via gRPC. Depends on: Phase 1 (need proto definitions for testing). `tai call` (tai repo) depends on this. -Tai gateway currently dials a fixed `YaoUpstream` at startup. New behavior: yao-grpc tells Tai where to forward via request metadata (`x-grpc-upstream`). Tai reads the target address and proxies to it — removes `YaoUpstream` startup config. +Tai gateway receives the upstream address during registration (`SetUpstream`). All gRPC requests are forwarded to the configured upstream — no per-request metadata required. | Task | Detail | Status | |------|--------|--------| -| Tai `gateway/gateway.go` | Remove fixed `upstream *grpc.ClientConn`. On each request, read `x-grpc-upstream` from metadata → lookup/create conn from `sync.Map` cache (key = address string) → forward. Typical deployment has 1 upstream, cache stays tiny. | ✅ Done | -| Tai `server/server.go` | Remove `YaoUpstream` from `Config`. Gateway init no longer needs an address. | ✅ Done | +| Tai `gateway/gateway.go` | Removed fixed `upstream *grpc.ClientConn`. `SetUpstream` configures the forwarding target. `sync.Map` cache for connections (key = address string). | ✅ Done | +| Tai `server/server.go` | Remove `YaoUpstream` from `Config`. Gateway uses `SetUpstream` after registration. | ✅ Done | | Tai `main.go` | Remove `--yao` flag, `TAI_YAO_UPSTREAM` env var, YAML `yao` field, and required check. | ✅ Done | -| Tai `gateway/gateway_test.go` | Updated tests: dynamic routing, missing metadata → InvalidArgument, metadata forwarding (x-grpc-upstream stripped), upstream error propagation, multiple upstreams, connection cache. Coverage: 88.8%. | ✅ Done | +| Tai `gateway/gateway_test.go` | Updated tests: SetUpstream routing, no-upstream → Unavailable, metadata forwarding, upstream error propagation, upstream switching, connection cache. | ✅ Done | Connection cache: `sync.Map[string, *grpc.ClientConn]` — lazy dial on first request per upstream, reuse thereafter. No eviction needed (upstream count ≈ 1 in practice). `Close` closes all cached connections. -Deliverable: Tai starts without Yao address. Forwards based on request metadata. +Deliverable: Tai receives upstream via registration. Forwards all requests to configured upstream. ### Phase 5: yao-grpc container client ✅ @@ -183,14 +183,11 @@ Depends on: Phase 1 (server + auth), Phase 4 (Tai gateway accepts `x-grpc-upstre | Task | Detail | Status | |------|--------|--------| -| `tai/grpc/auth.go` | `TokenManager`: read `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` / `YAO_GRPC_UPSTREAM` from env. `YAO_GRPC_TAI=enable` triggers Tai relay mode (requires `YAO_GRPC_UPSTREAM`). Attach as gRPC metadata on every call via unary + stream interceptors. Auto-refresh from response headers. | ✅ Done | -| `tai/grpc/grpc.go` | `Client`: `Dial(addr, TokenManager)`, `NewFromEnv()`. Method wrappers for all RPCs: Run, Shell, API, MCP (list/call/resources/read), ChatCompletions, ChatCompletionsStream, AgentStream, Healthz. | ✅ Done | -| `tai/grpc/cmd/main.go` | Stdio MCP server: JSON-RPC → gRPC. `yao-grpc version` prints version/commit/build time (via `-ldflags`). `yao-grpc serve` reads stdin JSON-RPC, dispatches to gRPC client. | ✅ Done | -| `tai/grpc/grpc_test.go` + `integration_test.go` | Black-box tests (package `grpc_test`). Unit: TokenManager metadata attachment, env parsing, refresh handling. Integration: real Yao gRPC server, all method wrappers, token refresh, auth rejection. Coverage: 83.9%. | ✅ Done | +| `tai call` (tai repo) | In-container gRPC bridge. Reads `YAO_TOKEN` / `YAO_REFRESH_TOKEN` / `YAO_SANDBOX_ID` / `YAO_GRPC_ADDR` from env. Attaches auth metadata on every call via unary + stream interceptors. Auto-refresh from response headers. | ✅ Done | Container token issuance uses existing `oauth.MakeAccessToken` / `oauth.MakeRefreshToken` — called by sandbox Manager at container creation, injected as env vars. Revoke on Remove. No new auth code needed on the issuance side. -Deliverable: `go build -o yao-grpc ./tai/grpc/cmd`. +Deliverable: `tai call` subcommand (part of Tai binary). ### Phase 6: Device Flow + CLI auth ✅ diff --git a/sandbox/DESIGN.md b/sandbox/DESIGN.md index c37aad70..a9775024 100644 --- a/sandbox/DESIGN.md +++ b/sandbox/DESIGN.md @@ -233,8 +233,8 @@ 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 | 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`. | +| IPC | Unix socket bind mount + yao-bridge | All modes: `tai call` → gRPC (direct or via Tai gateway). No Unix socket. | +| MCP config | `{args: ["/tmp/yao.sock"]}` hardcoded | `YAO_GRPC_ADDR` + `YAO_TOKEN` env vars. Local: direct. Remote: via Tai gateway. | | VNC | `vncproxy.NewProxy(nil)` local assumption | `tai.Client.VNC().URL()` | | Cleanup | `dockerClient.ContainerRemove` | `tai.Client.Sandbox().Remove()` | @@ -243,11 +243,11 @@ agent/context/jsapi_sandbox.go All modes use gRPC — no Unix socket fallback, one code path for local and remote. ``` -Local: Container → yao-grpc → Yao gRPC 127.0.0.1:9099 -Remote: Container → yao-grpc → Tai :9100 relay → Yao gRPC :9099 +Local: Container → tai call → Yao gRPC 127.0.0.1:9099 +Remote: Container → tai call → Tai :9100 gateway → Yao gRPC :9099 ``` -`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 call` is the in-container gRPC bridge (part of the Tai binary). It reads env vars and bridges JSON-RPC/stdio to the Yao gRPC server. Mode determined by env vars injected by Manager at container creation: @@ -255,13 +255,11 @@ 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 +# Remote: via Tai gateway 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 call` 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. The Tai gateway uses the upstream address configured during Tai registration to forward requests to Yao. 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. @@ -308,9 +306,9 @@ sandbox: 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. +3. **Tai gateway dynamic routing** (Tai repo) — removed fixed `YaoUpstream` startup config. Tai receives upstream address during registration (`SetUpstream`) and forwards all gRPC requests to it. 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`. +4. **`tai call` container client** (Tai repo) — in-container gRPC bridge replacing `yao-bridge`. Reads `YAO_TOKEN`/`YAO_REFRESH_TOKEN`/`YAO_SANDBOX_ID` from env, auto-refreshes tokens via response metadata. `YAO_GRPC_ADDR` determines the target (local Yao or remote Tai gateway). 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. diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index 19f521bb..f6797824 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -158,7 +158,7 @@ type Config struct { } ``` -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`. +Container gRPC env vars (`YAO_GRPC_ADDR`, etc.) are derived automatically at creation time. Per-instance settings (image, memory, CPU, workdir, env, pool) are passed via `CreateOptions`. ### Core API @@ -548,10 +548,8 @@ YAO_TOKEN= YAO_REFRESH_TOKEN= YAO_GRPC_ADDR=127.0.0.1:9099 -# Remote mode (tai://) adds: -YAO_GRPC_TAI=enable +# Remote mode (tai://) YAO_GRPC_ADDR=:9100 -YAO_GRPC_UPSTREAM=127.0.0.1:9099 ``` ## Errors diff --git a/sandbox/v2/TEST.md b/sandbox/v2/TEST.md index 252b764f..a5a3459e 100644 --- a/sandbox/v2/TEST.md +++ b/sandbox/v2/TEST.md @@ -468,13 +468,11 @@ func TestBuildGRPCEnv_Local(t *testing.T) { 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"]) + assert.NotEmpty(t, env["YAO_GRPC_ADDR"]) } func TestCreateContainerTokens(t *testing.T) { diff --git a/sandbox/v2/box.go b/sandbox/v2/box.go index 5cb5bfc0..2eb4a943 100644 --- a/sandbox/v2/box.go +++ b/sandbox/v2/box.go @@ -2,10 +2,12 @@ package sandbox import ( "context" + "fmt" "io" "sync/atomic" "time" + hepb "github.com/yaoapp/yao/tai/hostexec/pb" "github.com/yaoapp/yao/tai/proxy" taisandbox "github.com/yaoapp/yao/tai/sandbox" "github.com/yaoapp/yao/tai/workspace" @@ -281,3 +283,49 @@ func (b *Box) stopTimeout() time.Duration { } return DefaultStopTimeout } + +// ExecOnHost runs a command on the Tai host machine (not inside the container). +// Returns an error if the pool uses a local Docker connection (no Tai server). +func (b *Box) ExecOnHost(ctx context.Context, cmd string, args []string, opts ...HostExecOption) (*HostExecResult, error) { + b.touch() + client, err := b.manager.getPool(b.pool) + if err != nil { + return nil, err + } + + he := client.HostExec() + if he == nil { + return nil, fmt.Errorf("hostexec not available on pool %q (local mode)", b.pool) + } + + cfg := &hostExecConfig{} + for _, o := range opts { + o(cfg) + } + + req := &hepb.ExecRequest{ + Command: cmd, + Args: args, + WorkingDir: cfg.WorkDir, + Stdin: cfg.Stdin, + TimeoutMs: cfg.TimeoutMs, + MaxOutputBytes: cfg.MaxOutputBytes, + } + if cfg.Env != nil { + req.Env = cfg.Env + } + + resp, err := he.Exec(ctx, req) + if err != nil { + return nil, fmt.Errorf("hostexec rpc: %w", err) + } + + return &HostExecResult{ + ExitCode: int(resp.ExitCode), + Stdout: resp.Stdout, + Stderr: resp.Stderr, + DurationMs: resp.DurationMs, + Error: resp.Error, + Truncated: resp.Truncated, + }, nil +} diff --git a/sandbox/v2/box_hostexec_test.go b/sandbox/v2/box_hostexec_test.go new file mode 100644 index 00000000..1da4bfe9 --- /dev/null +++ b/sandbox/v2/box_hostexec_test.go @@ -0,0 +1,359 @@ +package sandbox_test + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + sandbox "github.com/yaoapp/yao/sandbox/v2" + "github.com/yaoapp/yao/tai" + hepb "github.com/yaoapp/yao/tai/hostexec/pb" +) + +func hostExecClient(t *testing.T, tgt hostExecTarget) hepb.HostExecClient { + t.Helper() + addr := fmt.Sprintf("tai://%s", tgt.Addr) + client, err := tai.New(addr) + if err != nil { + t.Skipf("tai.New(%s): %v", addr, err) + return nil + } + t.Cleanup(func() { client.Close() }) + he := client.HostExec() + if he == nil { + t.Skipf("hostexec not available on %s", tgt.Name) + return nil + } + + probeCmd, probeArgs := linuxCmd(tgt, "echo", "probe") + probe, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err = he.Exec(probe, &hepb.ExecRequest{Command: probeCmd, Args: probeArgs}) + if err != nil { + client.Close() + t.Skipf("hostexec on %s unreachable: %v", tgt.Name, err) + return nil + } + return he +} + +func linuxCmd(tgt hostExecTarget, cmd string, args ...string) (string, []string) { + if tgt.IsWinNative { + switch cmd { + case "echo": + return "cmd.exe", append([]string{"/c", "echo"}, args...) + case "pwd": + return "cmd.exe", []string{"/c", "cd"} + case "env": + return "cmd.exe", []string{"/c", "set"} + case "sleep": + return "cmd.exe", []string{"/c", "ping", "-n", "10", "127.0.0.1"} + case "cat": + return "cmd.exe", []string{"/c", "more"} + case "sh": + if len(args) >= 2 && args[0] == "-c" { + return "cmd.exe", []string{"/c", args[1]} + } + return "cmd.exe", append([]string{"/c"}, args...) + default: + return cmd, args + } + } + return cmd, args +} + +func TestHostExec_Echo(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd, args := linuxCmd(tgt, "echo", "hello", "from", "host") + resp, err := he.Exec(ctx, &hepb.ExecRequest{Command: cmd, Args: args}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" { + if strings.Contains(resp.Error, "not in the allowed list") { + t.Skipf("command not allowed on %s", tgt.Name) + } + t.Fatalf("error: %s", resp.Error) + } + if resp.ExitCode != 0 { + t.Errorf("exit_code = %d, want 0", resp.ExitCode) + } + got := strings.TrimSpace(string(resp.Stdout)) + if !strings.Contains(got, "hello") { + t.Errorf("stdout = %q, want contains 'hello'", got) + } + }) + } +} + +func TestHostExec_Env(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd, args := linuxCmd(tgt, "env") + resp, err := he.Exec(ctx, &hepb.ExecRequest{Command: cmd, Args: args}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" { + if strings.Contains(resp.Error, "not in the allowed list") { + t.Skipf("command not allowed on %s", tgt.Name) + } + t.Fatalf("error: %s", resp.Error) + } + out := string(resp.Stdout) + if out == "" { + t.Error("stdout is empty, expected environment variables") + } + }) + } +} + +func TestHostExec_Timeout(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd, args := linuxCmd(tgt, "sleep", "10") + resp, err := he.Exec(ctx, &hepb.ExecRequest{ + Command: cmd, + Args: args, + TimeoutMs: 200, + }) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" && strings.Contains(resp.Error, "not in the allowed list") { + t.Skipf("command not allowed on %s", tgt.Name) + } + if !strings.Contains(resp.Error, "timed out") { + t.Errorf("error = %q, want contains 'timed out'", resp.Error) + } + }) + } +} + +func TestHostExec_WorkingDir(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd, args := linuxCmd(tgt, "pwd") + workDir := "/tmp" + if tgt.IsWinNative { + workDir = "C:\\Windows\\Temp" + } + + resp, err := he.Exec(ctx, &hepb.ExecRequest{ + Command: cmd, + Args: args, + WorkingDir: workDir, + }) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" { + if strings.Contains(resp.Error, "not in") && strings.Contains(resp.Error, "allowed") { + t.Skipf("working_dir not allowed on %s: %s", tgt.Name, resp.Error) + } + t.Fatalf("error: %s", resp.Error) + } + got := strings.TrimSpace(string(resp.Stdout)) + if got == "" { + t.Error("stdout is empty") + } + }) + } +} + +func TestHostExec_Stdin(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd, args := linuxCmd(tgt, "cat") + resp, err := he.Exec(ctx, &hepb.ExecRequest{ + Command: cmd, + Args: args, + Stdin: []byte("piped input"), + }) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" { + if strings.Contains(resp.Error, "not in the allowed list") { + t.Skipf("command not allowed on %s", tgt.Name) + } + t.Fatalf("error: %s", resp.Error) + } + got := string(resp.Stdout) + if !strings.Contains(got, "piped input") { + t.Errorf("stdout = %q, want contains 'piped input'", got) + } + }) + } +} + +func TestHostExec_NonZeroExit(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var cmd string + var args []string + if tgt.IsWinNative { + cmd = "cmd.exe" + args = []string{"/c", "exit", "42"} + } else { + cmd = "sh" + args = []string{"-c", "exit 42"} + } + + resp, err := he.Exec(ctx, &hepb.ExecRequest{Command: cmd, Args: args}) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" { + if strings.Contains(resp.Error, "not in the allowed list") { + t.Skipf("command not allowed on %s", tgt.Name) + } + } + if resp.ExitCode != 42 { + t.Errorf("exit_code = %d, want 42", resp.ExitCode) + } + }) + } +} + +func TestHostExec_UserEnv(t *testing.T) { + skipIfNoHostExec(t) + + for _, tgt := range hostExecTargets() { + t.Run(tgt.Name, func(t *testing.T) { + he := hostExecClient(t, tgt) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var cmd string + var args []string + if tgt.IsWinNative { + cmd = "cmd.exe" + args = []string{"/c", "echo", "%MY_VAR%"} + } else { + cmd = "sh" + args = []string{"-c", "echo $MY_VAR"} + } + + resp, err := he.Exec(ctx, &hepb.ExecRequest{ + Command: cmd, + Args: args, + Env: map[string]string{"MY_VAR": "test_value"}, + }) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if resp.Error != "" { + if strings.Contains(resp.Error, "not in the allowed list") { + t.Skipf("command not allowed on %s", tgt.Name) + } + t.Fatalf("error: %s", resp.Error) + } + got := strings.TrimSpace(string(resp.Stdout)) + if !strings.Contains(got, "test_value") { + t.Errorf("stdout = %q, want contains 'test_value'", got) + } + }) + } +} + +// TestHostExec_LocalUnavailable verifies ExecOnHost returns an error for local pools. +func TestHostExec_LocalUnavailable(t *testing.T) { + skipIfNoDocker(t) + + m := setupManagerForPool(t, poolConfig{Name: "local", Addr: testLocalAddr()}) + box := createTestBox(t, m) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := box.ExecOnHost(ctx, "echo", []string{"should fail"}) + if err == nil { + t.Fatal("expected error for local pool, got nil") + } + if !strings.Contains(err.Error(), "not available") { + t.Errorf("error = %q, expected 'not available'", err.Error()) + } +} + +// TestHostExec_BoxIntegration verifies ExecOnHost works through a sandbox Box +// (requires container creation — only tests pools with Docker/K8s support). +func TestHostExec_BoxIntegration(t *testing.T) { + skipIfNoTai(t) + + for _, pc := range testPools() { + if pc.Name == "local" { + continue + } + t.Run(pc.Name, func(t *testing.T) { + m := setupManagerForPool(t, pc) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pool := pc.Name + if err := m.EnsureImage(ctx, pool, testImage(), sandbox.ImagePullOptions{}); err != nil { + t.Skipf("pool %s unavailable (image check): %v", pool, err) + } + box, err := m.Create(ctx, sandbox.CreateOptions{Image: testImage(), Owner: "test-user"}) + if err != nil { + t.Skipf("pool %s unavailable (create): %v", pool, err) + } + t.Cleanup(func() { m.Remove(context.Background(), box.ID()) }) + + result, err := box.ExecOnHost(ctx, "echo", []string{"box", "integration"}) + if err != nil { + t.Skipf("ExecOnHost unavailable on pool %s: %v", pc.Name, err) + } + if result.Error != "" { + if strings.Contains(result.Error, "not in the allowed list") { + t.Skipf("echo not in allowed commands on pool %s", pc.Name) + } + t.Fatalf("hostexec error: %s", result.Error) + } + got := strings.TrimSpace(string(result.Stdout)) + if !strings.Contains(got, "box") || !strings.Contains(got, "integration") { + t.Errorf("stdout = %q, want contains 'box integration'", got) + } + }) + } +} diff --git a/sandbox/v2/grpc.go b/sandbox/v2/grpc.go index e676d5a7..41561d45 100644 --- a/sandbox/v2/grpc.go +++ b/sandbox/v2/grpc.go @@ -52,9 +52,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m switch { case strings.HasPrefix(pool.Addr, "tunnel://"): - env["YAO_GRPC_TAI"] = "enable" env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%d", grpcPort) - env["YAO_GRPC_UPSTREAM"] = fmt.Sprintf("127.0.0.1:%s", portStr) case strings.HasPrefix(pool.Addr, "tai://"): u, err := url.Parse(pool.Addr) @@ -67,9 +65,7 @@ func BuildGRPCEnv(pool *Pool, sandboxID, access, refresh string, grpcPort int) m if taiPort == "" { taiPort = "9100" } - env["YAO_GRPC_TAI"] = "enable" env["YAO_GRPC_ADDR"] = fmt.Sprintf("%s:%s", taiHost, taiPort) - env["YAO_GRPC_UPSTREAM"] = fmt.Sprintf("127.0.0.1:%s", portStr) default: env["YAO_GRPC_ADDR"] = fmt.Sprintf("127.0.0.1:%s", portStr) diff --git a/sandbox/v2/grpc_test.go b/sandbox/v2/grpc_test.go index 8e567e24..a0462406 100644 --- a/sandbox/v2/grpc_test.go +++ b/sandbox/v2/grpc_test.go @@ -19,23 +19,23 @@ func TestBuildGRPCEnvLocal(t *testing.T) { 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"]) + t.Errorf("YAO_GRPC_ADDR = %q, want gpu-server:9100", 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 TestBuildGRPCEnvTunnel(t *testing.T) { + pool := &sandbox.Pool{Name: "tunnel", Addr: "tunnel://relay.example.com"} + env := sandbox.BuildGRPCEnv(pool, "sb-003", "access", "refresh", 9099) + + if env["YAO_GRPC_ADDR"] != "127.0.0.1:9099" { + t.Errorf("YAO_GRPC_ADDR = %q, want 127.0.0.1:9099", env["YAO_GRPC_ADDR"]) } } diff --git a/sandbox/v2/testutils_test.go b/sandbox/v2/testutils_test.go index ff4b512d..04968e8d 100644 --- a/sandbox/v2/testutils_test.go +++ b/sandbox/v2/testutils_test.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "strconv" + "strings" + "sync" "testing" "time" @@ -14,6 +16,13 @@ import ( "github.com/yaoapp/yao/workspace" ) +// k8sSem limits concurrent K8s pod creation to avoid overwhelming the cluster. +var k8sSem = make(chan struct{}, 2) + +// k8sCleanupMu serialises K8s pod cleanup to prevent overlapping API calls +// when many tests finish at once. +var k8sCleanupMu sync.Mutex + type poolConfig struct { Name string Addr string @@ -77,6 +86,40 @@ func skipIfNoTai(t *testing.T) { } } +type hostExecTarget struct { + Name string + Addr string // host:port (without tai:// prefix) + IsWinNative bool +} + +// hostExecTargets returns all Tai instances that support HostExec gRPC. +// No container creation needed — these are direct gRPC connections. +func hostExecTargets() []hostExecTarget { + var targets []hostExecTarget + if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" { + addr = strings.TrimPrefix(addr, "tai://") + targets = append(targets, hostExecTarget{Name: "remote", Addr: addr}) + } + if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" { + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) + targets = append(targets, hostExecTarget{Name: "k8s", Addr: fmt.Sprintf("%s:%d", host, grpcPort)}) + } + if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_LINUX"); addr != "" { + targets = append(targets, hostExecTarget{Name: "win-linux", Addr: addr}) + } + if addr := os.Getenv("TAI_TEST_WIN_HOSTEXEC_NATIVE"); addr != "" { + targets = append(targets, hostExecTarget{Name: "win-native", Addr: addr, IsWinNative: true}) + } + return targets +} + +func skipIfNoHostExec(t *testing.T) { + t.Helper() + if len(hostExecTargets()) == 0 { + t.Skip("no HostExec targets configured") + } +} + func testLocalAddr() string { if addr := os.Getenv("SANDBOX_TEST_LOCAL_ADDR"); addr != "" { return addr @@ -176,21 +219,43 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat } } - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + isK8s := pool == "k8s" + if isK8s { + k8sSem <- struct{}{} + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if pool != "" { if err := m.EnsureImage(ctx, pool, co.Image, sandbox.ImagePullOptions{}); err != nil { + if isK8s { + <-k8sSem + } t.Fatalf("EnsureImage(%s, %s): %v", pool, co.Image, err) } } box, err := m.Create(ctx, co) if err != nil { + if isK8s { + <-k8sSem + } t.Fatalf("Create: %v", err) } t.Cleanup(func() { - m.Remove(context.Background(), box.ID()) + cleanCtx, cleanCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cleanCancel() + if isK8s { + k8sCleanupMu.Lock() + defer k8sCleanupMu.Unlock() + } + if err := m.Remove(cleanCtx, box.ID()); err != nil { + t.Logf("cleanup Remove(%s): %v", box.ID(), err) + } + if isK8s { + <-k8sSem + } }) return box } diff --git a/sandbox/v2/types.go b/sandbox/v2/types.go index 4d72a0c4..5ed8a4f7 100644 --- a/sandbox/v2/types.go +++ b/sandbox/v2/types.go @@ -176,3 +176,44 @@ type BoxInfo struct { ProcessCount int VNC bool } + +// HostExecResult holds the outcome of a command executed on the Tai host. +type HostExecResult struct { + ExitCode int + Stdout []byte + Stderr []byte + DurationMs int64 + Error string + Truncated bool +} + +type hostExecConfig struct { + WorkDir string + Env map[string]string + Stdin []byte + TimeoutMs int64 + MaxOutputBytes int64 +} + +// HostExecOption configures an ExecOnHost call. +type HostExecOption func(*hostExecConfig) + +func WithHostWorkDir(dir string) HostExecOption { + return func(c *hostExecConfig) { c.WorkDir = dir } +} + +func WithHostEnv(env map[string]string) HostExecOption { + return func(c *hostExecConfig) { c.Env = env } +} + +func WithHostStdin(data []byte) HostExecOption { + return func(c *hostExecConfig) { c.Stdin = data } +} + +func WithHostTimeout(ms int64) HostExecOption { + return func(c *hostExecConfig) { c.TimeoutMs = ms } +} + +func WithHostMaxOutput(bytes int64) HostExecOption { + return func(c *hostExecConfig) { c.MaxOutputBytes = bytes } +} diff --git a/tai/hostexec/pb/hostexec.pb.go b/tai/hostexec/pb/hostexec.pb.go new file mode 100644 index 00000000..3a178d26 --- /dev/null +++ b/tai/hostexec/pb/hostexec.pb.go @@ -0,0 +1,424 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v4.25.0 +// source: hostexec/pb/hostexec.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 ExecOutput_Stream int32 + +const ( + ExecOutput_STDOUT ExecOutput_Stream = 0 + ExecOutput_STDERR ExecOutput_Stream = 1 +) + +// Enum value maps for ExecOutput_Stream. +var ( + ExecOutput_Stream_name = map[int32]string{ + 0: "STDOUT", + 1: "STDERR", + } + ExecOutput_Stream_value = map[string]int32{ + "STDOUT": 0, + "STDERR": 1, + } +) + +func (x ExecOutput_Stream) Enum() *ExecOutput_Stream { + p := new(ExecOutput_Stream) + *p = x + return p +} + +func (x ExecOutput_Stream) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExecOutput_Stream) Descriptor() protoreflect.EnumDescriptor { + return file_hostexec_pb_hostexec_proto_enumTypes[0].Descriptor() +} + +func (ExecOutput_Stream) Type() protoreflect.EnumType { + return &file_hostexec_pb_hostexec_proto_enumTypes[0] +} + +func (x ExecOutput_Stream) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExecOutput_Stream.Descriptor instead. +func (ExecOutput_Stream) EnumDescriptor() ([]byte, []int) { + return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{2, 0} +} + +type ExecRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + WorkingDir string `protobuf:"bytes,3,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"` + Env map[string]string `protobuf:"bytes,4,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Stdin []byte `protobuf:"bytes,5,opt,name=stdin,proto3" json:"stdin,omitempty"` + TimeoutMs int64 `protobuf:"varint,6,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"` + MaxOutputBytes int64 `protobuf:"varint,7,opt,name=max_output_bytes,json=maxOutputBytes,proto3" json:"max_output_bytes,omitempty"` // max stdout+stderr size (0 = default 10MB), truncate if exceeded + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRequest) Reset() { + *x = ExecRequest{} + mi := &file_hostexec_pb_hostexec_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest) ProtoMessage() {} + +func (x *ExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostexec_pb_hostexec_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 ExecRequest.ProtoReflect.Descriptor instead. +func (*ExecRequest) Descriptor() ([]byte, []int) { + return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{0} +} + +func (x *ExecRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *ExecRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ExecRequest) GetWorkingDir() string { + if x != nil { + return x.WorkingDir + } + return "" +} + +func (x *ExecRequest) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + +func (x *ExecRequest) GetStdin() []byte { + if x != nil { + return x.Stdin + } + return nil +} + +func (x *ExecRequest) GetTimeoutMs() int64 { + if x != nil { + return x.TimeoutMs + } + return 0 +} + +func (x *ExecRequest) GetMaxOutputBytes() int64 { + if x != nil { + return x.MaxOutputBytes + } + return 0 +} + +type ExecResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Stdout []byte `protobuf:"bytes,2,opt,name=stdout,proto3" json:"stdout,omitempty"` + Stderr []byte `protobuf:"bytes,3,opt,name=stderr,proto3" json:"stderr,omitempty"` + DurationMs int64 `protobuf:"varint,4,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` // non-empty if Tai failed to execute (not the command's error) + Truncated bool `protobuf:"varint,6,opt,name=truncated,proto3" json:"truncated,omitempty"` // true if stdout+stderr exceeded max_output_bytes and was truncated + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecResponse) Reset() { + *x = ExecResponse{} + mi := &file_hostexec_pb_hostexec_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResponse) ProtoMessage() {} + +func (x *ExecResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostexec_pb_hostexec_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 ExecResponse.ProtoReflect.Descriptor instead. +func (*ExecResponse) Descriptor() ([]byte, []int) { + return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{1} +} + +func (x *ExecResponse) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ExecResponse) GetStdout() []byte { + if x != nil { + return x.Stdout + } + return nil +} + +func (x *ExecResponse) GetStderr() []byte { + if x != nil { + return x.Stderr + } + return nil +} + +func (x *ExecResponse) GetDurationMs() int64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +func (x *ExecResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *ExecResponse) GetTruncated() bool { + if x != nil { + return x.Truncated + } + return false +} + +type ExecOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Stream ExecOutput_Stream `protobuf:"varint,1,opt,name=stream,proto3,enum=hostexec.ExecOutput_Stream" json:"stream,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + // Only set in the final message (exit_code is meaningful). + Done bool `protobuf:"varint,3,opt,name=done,proto3" json:"done,omitempty"` + ExitCode int32 `protobuf:"varint,4,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecOutput) Reset() { + *x = ExecOutput{} + mi := &file_hostexec_pb_hostexec_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecOutput) ProtoMessage() {} + +func (x *ExecOutput) ProtoReflect() protoreflect.Message { + mi := &file_hostexec_pb_hostexec_proto_msgTypes[2] + 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 ExecOutput.ProtoReflect.Descriptor instead. +func (*ExecOutput) Descriptor() ([]byte, []int) { + return file_hostexec_pb_hostexec_proto_rawDescGZIP(), []int{2} +} + +func (x *ExecOutput) GetStream() ExecOutput_Stream { + if x != nil { + return x.Stream + } + return ExecOutput_STDOUT +} + +func (x *ExecOutput) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *ExecOutput) GetDone() bool { + if x != nil { + return x.Done + } + return false +} + +func (x *ExecOutput) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ExecOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +var File_hostexec_pb_hostexec_proto protoreflect.FileDescriptor + +const file_hostexec_pb_hostexec_proto_rawDesc = "" + + "\n" + + "\x1ahostexec/pb/hostexec.proto\x12\bhostexec\"\xa5\x02\n" + + "\vExecRequest\x12\x18\n" + + "\acommand\x18\x01 \x01(\tR\acommand\x12\x12\n" + + "\x04args\x18\x02 \x03(\tR\x04args\x12\x1f\n" + + "\vworking_dir\x18\x03 \x01(\tR\n" + + "workingDir\x120\n" + + "\x03env\x18\x04 \x03(\v2\x1e.hostexec.ExecRequest.EnvEntryR\x03env\x12\x14\n" + + "\x05stdin\x18\x05 \x01(\fR\x05stdin\x12\x1d\n" + + "\n" + + "timeout_ms\x18\x06 \x01(\x03R\ttimeoutMs\x12(\n" + + "\x10max_output_bytes\x18\a \x01(\x03R\x0emaxOutputBytes\x1a6\n" + + "\bEnvEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb0\x01\n" + + "\fExecResponse\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x16\n" + + "\x06stdout\x18\x02 \x01(\fR\x06stdout\x12\x16\n" + + "\x06stderr\x18\x03 \x01(\fR\x06stderr\x12\x1f\n" + + "\vduration_ms\x18\x04 \x01(\x03R\n" + + "durationMs\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\x12\x1c\n" + + "\ttruncated\x18\x06 \x01(\bR\ttruncated\"\xbe\x01\n" + + "\n" + + "ExecOutput\x123\n" + + "\x06stream\x18\x01 \x01(\x0e2\x1b.hostexec.ExecOutput.StreamR\x06stream\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\x12\x12\n" + + "\x04done\x18\x03 \x01(\bR\x04done\x12\x1b\n" + + "\texit_code\x18\x04 \x01(\x05R\bexitCode\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\" \n" + + "\x06Stream\x12\n" + + "\n" + + "\x06STDOUT\x10\x00\x12\n" + + "\n" + + "\x06STDERR\x10\x012~\n" + + "\bHostExec\x125\n" + + "\x04Exec\x12\x15.hostexec.ExecRequest\x1a\x16.hostexec.ExecResponse\x12;\n" + + "\n" + + "ExecStream\x12\x15.hostexec.ExecRequest\x1a\x14.hostexec.ExecOutput0\x01B#Z!github.com/yaoapp/tai/hostexec/pbb\x06proto3" + +var ( + file_hostexec_pb_hostexec_proto_rawDescOnce sync.Once + file_hostexec_pb_hostexec_proto_rawDescData []byte +) + +func file_hostexec_pb_hostexec_proto_rawDescGZIP() []byte { + file_hostexec_pb_hostexec_proto_rawDescOnce.Do(func() { + file_hostexec_pb_hostexec_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_hostexec_pb_hostexec_proto_rawDesc), len(file_hostexec_pb_hostexec_proto_rawDesc))) + }) + return file_hostexec_pb_hostexec_proto_rawDescData +} + +var file_hostexec_pb_hostexec_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_hostexec_pb_hostexec_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_hostexec_pb_hostexec_proto_goTypes = []any{ + (ExecOutput_Stream)(0), // 0: hostexec.ExecOutput.Stream + (*ExecRequest)(nil), // 1: hostexec.ExecRequest + (*ExecResponse)(nil), // 2: hostexec.ExecResponse + (*ExecOutput)(nil), // 3: hostexec.ExecOutput + nil, // 4: hostexec.ExecRequest.EnvEntry +} +var file_hostexec_pb_hostexec_proto_depIdxs = []int32{ + 4, // 0: hostexec.ExecRequest.env:type_name -> hostexec.ExecRequest.EnvEntry + 0, // 1: hostexec.ExecOutput.stream:type_name -> hostexec.ExecOutput.Stream + 1, // 2: hostexec.HostExec.Exec:input_type -> hostexec.ExecRequest + 1, // 3: hostexec.HostExec.ExecStream:input_type -> hostexec.ExecRequest + 2, // 4: hostexec.HostExec.Exec:output_type -> hostexec.ExecResponse + 3, // 5: hostexec.HostExec.ExecStream:output_type -> hostexec.ExecOutput + 4, // [4:6] is the sub-list for method output_type + 2, // [2:4] 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_hostexec_pb_hostexec_proto_init() } +func file_hostexec_pb_hostexec_proto_init() { + if File_hostexec_pb_hostexec_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostexec_pb_hostexec_proto_rawDesc), len(file_hostexec_pb_hostexec_proto_rawDesc)), + NumEnums: 1, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_hostexec_pb_hostexec_proto_goTypes, + DependencyIndexes: file_hostexec_pb_hostexec_proto_depIdxs, + EnumInfos: file_hostexec_pb_hostexec_proto_enumTypes, + MessageInfos: file_hostexec_pb_hostexec_proto_msgTypes, + }.Build() + File_hostexec_pb_hostexec_proto = out.File + file_hostexec_pb_hostexec_proto_goTypes = nil + file_hostexec_pb_hostexec_proto_depIdxs = nil +} diff --git a/tai/hostexec/pb/hostexec.proto b/tai/hostexec/pb/hostexec.proto new file mode 100644 index 00000000..6ddac9b8 --- /dev/null +++ b/tai/hostexec/pb/hostexec.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; +package hostexec; +option go_package = "github.com/yaoapp/yao/tai/hostexec/pb"; + +// HostExec provides remote command execution on the Tai host machine. +// High privilege — enabled only when --host-exec flag is set. +service HostExec { + // Exec runs a command and returns the result when it completes. + rpc Exec(ExecRequest) returns (ExecResponse); + + // ExecStream runs a command and streams stdout/stderr in real time. + rpc ExecStream(ExecRequest) returns (stream ExecOutput); +} + +message ExecRequest { + string command = 1; + repeated string args = 2; + string working_dir = 3; + map env = 4; + bytes stdin = 5; + int64 timeout_ms = 6; + int64 max_output_bytes = 7; // max stdout+stderr size (0 = default 10MB), truncate if exceeded +} + +message ExecResponse { + int32 exit_code = 1; + bytes stdout = 2; + bytes stderr = 3; + int64 duration_ms = 4; + string error = 5; // non-empty if Tai failed to execute (not the command's error) + bool truncated = 6; // true if stdout+stderr exceeded max_output_bytes and was truncated +} + +message ExecOutput { + enum Stream { + STDOUT = 0; + STDERR = 1; + } + Stream stream = 1; + bytes data = 2; + + // Only set in the final message (exit_code is meaningful). + bool done = 3; + int32 exit_code = 4; + string error = 5; +} diff --git a/tai/hostexec/pb/hostexec_grpc.pb.go b/tai/hostexec/pb/hostexec_grpc.pb.go new file mode 100644 index 00000000..a8804b4f --- /dev/null +++ b/tai/hostexec/pb/hostexec_grpc.pb.go @@ -0,0 +1,173 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v4.25.0 +// source: hostexec/pb/hostexec.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 ( + HostExec_Exec_FullMethodName = "/hostexec.HostExec/Exec" + HostExec_ExecStream_FullMethodName = "/hostexec.HostExec/ExecStream" +) + +// HostExecClient is the client API for HostExec 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. +// +// HostExec provides remote command execution on the Tai host machine. +// High privilege — enabled only when --host-exec flag is set. +type HostExecClient interface { + // Exec runs a command and returns the result when it completes. + Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error) + // ExecStream runs a command and streams stdout/stderr in real time. + ExecStream(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecOutput], error) +} + +type hostExecClient struct { + cc grpc.ClientConnInterface +} + +func NewHostExecClient(cc grpc.ClientConnInterface) HostExecClient { + return &hostExecClient{cc} +} + +func (c *hostExecClient) Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (*ExecResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExecResponse) + err := c.cc.Invoke(ctx, HostExec_Exec_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostExecClient) ExecStream(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecOutput], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &HostExec_ServiceDesc.Streams[0], HostExec_ExecStream_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExecRequest, ExecOutput]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type HostExec_ExecStreamClient = grpc.ServerStreamingClient[ExecOutput] + +// HostExecServer is the server API for HostExec service. +// All implementations must embed UnimplementedHostExecServer +// for forward compatibility. +// +// HostExec provides remote command execution on the Tai host machine. +// High privilege — enabled only when --host-exec flag is set. +type HostExecServer interface { + // Exec runs a command and returns the result when it completes. + Exec(context.Context, *ExecRequest) (*ExecResponse, error) + // ExecStream runs a command and streams stdout/stderr in real time. + ExecStream(*ExecRequest, grpc.ServerStreamingServer[ExecOutput]) error + mustEmbedUnimplementedHostExecServer() +} + +// UnimplementedHostExecServer 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 UnimplementedHostExecServer struct{} + +func (UnimplementedHostExecServer) Exec(context.Context, *ExecRequest) (*ExecResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Exec not implemented") +} +func (UnimplementedHostExecServer) ExecStream(*ExecRequest, grpc.ServerStreamingServer[ExecOutput]) error { + return status.Error(codes.Unimplemented, "method ExecStream not implemented") +} +func (UnimplementedHostExecServer) mustEmbedUnimplementedHostExecServer() {} +func (UnimplementedHostExecServer) testEmbeddedByValue() {} + +// UnsafeHostExecServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to HostExecServer will +// result in compilation errors. +type UnsafeHostExecServer interface { + mustEmbedUnimplementedHostExecServer() +} + +func RegisterHostExecServer(s grpc.ServiceRegistrar, srv HostExecServer) { + // If the following call panics, it indicates UnimplementedHostExecServer 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(&HostExec_ServiceDesc, srv) +} + +func _HostExec_Exec_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostExecServer).Exec(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: HostExec_Exec_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostExecServer).Exec(ctx, req.(*ExecRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _HostExec_ExecStream_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ExecRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(HostExecServer).ExecStream(m, &grpc.GenericServerStream[ExecRequest, ExecOutput]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type HostExec_ExecStreamServer = grpc.ServerStreamingServer[ExecOutput] + +// HostExec_ServiceDesc is the grpc.ServiceDesc for HostExec service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var HostExec_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "hostexec.HostExec", + HandlerType: (*HostExecServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Exec", + Handler: _HostExec_Exec_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "ExecStream", + Handler: _HostExec_ExecStream_Handler, + ServerStreams: true, + }, + }, + Metadata: "hostexec/pb/hostexec.proto", +} diff --git a/tai/tai.go b/tai/tai.go index 76acf990..958c8140 100644 --- a/tai/tai.go +++ b/tai/tai.go @@ -10,6 +10,7 @@ import ( "strings" "time" + hepb "github.com/yaoapp/yao/tai/hostexec/pb" "github.com/yaoapp/yao/tai/proxy" "github.com/yaoapp/yao/tai/registry" "github.com/yaoapp/yao/tai/sandbox" @@ -136,6 +137,7 @@ type Client struct { img sandbox.Image prx proxy.Proxy vc vnc.VNC + he hepb.HostExecClient grpcConn *grpc.ClientConn // tunnel mode: local listeners that bridge to Tai via WS @@ -217,6 +219,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) { return nil, fmt.Errorf("grpc dial %s: %w", grpcAddr, err) } c.grpcConn = conn + c.he = hepb.NewHostExecClient(conn) // Auto-discover server ports via ServerInfo RPC. // Only overwrite ports that were NOT explicitly set by WithPorts. @@ -315,6 +318,7 @@ func (c *Client) initTunnel(cfg *config) (*Client, error) { return nil, fmt.Errorf("grpc dial tunnel %s: %w", grpcAddr, err) } c.grpcConn = conn + c.he = hepb.NewHostExecClient(conn) c.vol = volume.NewRemote(conn) dockerLn, err := reg.OpenLocalListener(taiID, c.ports.Docker) @@ -408,6 +412,10 @@ func (c *Client) Proxy() proxy.Proxy { return c.prx } // VNC returns the VNC WebSocket helper. Never nil. func (c *Client) VNC() vnc.VNC { return c.vc } +// HostExec returns the HostExec gRPC client for executing commands on the Tai +// host machine. Returns nil in local mode (no Tai server). +func (c *Client) HostExec() hepb.HostExecClient { return c.he } + // IsLocal returns true if the client connects directly to a Docker daemon. func (c *Client) IsLocal() bool { return c.scheme == "docker" }