Enhance Sandbox V2 testing and integration with Workspace
- Introduce new unit test targets for Workspace and Sandbox V2 integration in the Makefile, ensuring comprehensive testing of the new features. - Update CI workflows to support MongoDB service for Sandbox V2 tests and improve Docker image handling. - Implement Workspace as a first-class entity, allowing for persistent storage decoupled from container lifecycles. - Enhance the Box struct to manage workspace IDs and update related methods for improved functionality. - Refactor tests to accommodate new Workspace features, ensuring robust testing of the integration. These changes significantly improve the testing framework and functionality of the Sandbox V2, enhancing the overall architecture and user experience.
This commit is contained in:
parent
2bd2a37c4b
commit
d25bfefee0
34 changed files with 3471 additions and 560 deletions
190
.github/workflows/pr-test.yml
vendored
190
.github/workflows/pr-test.yml
vendored
|
|
@ -924,10 +924,20 @@ jobs:
|
||||||
});
|
});
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Sandbox V2 Tests (requires Docker + Tai for dual-mode)
|
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
SandboxV2Test:
|
SandboxV2Test:
|
||||||
runs-on: ubuntu-latest
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go: ["1.25"]
|
go: ["1.25"]
|
||||||
|
|
@ -975,7 +985,7 @@ jobs:
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: issue_number,
|
issue_number: issue_number,
|
||||||
body: '🤖 Sandbox V2 Tests running (dual-mode: local + remote)...'
|
body: '🤖 Sandbox V2 Tests running (tai + sandbox-v2 + workspace)...'
|
||||||
});
|
});
|
||||||
|
|
||||||
- name: Checkout Kun
|
- name: Checkout Kun
|
||||||
|
|
@ -1038,48 +1048,107 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.HEAD }}
|
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 }}
|
- name: Setup Go ${{ matrix.go }}
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
|
|
||||||
|
- name: Start Redis
|
||||||
|
run: docker run --name redis --publish 6379:6379 --detach redis:6
|
||||||
|
|
||||||
- name: Setup Go Tools
|
- name: Setup Go Tools
|
||||||
run: make 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
|
- name: Pull Test Images
|
||||||
run: |
|
run: |
|
||||||
docker pull yaoapp/sandbox-v2-test:latest || true
|
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||||
docker pull yaoapp/tai:latest
|
docker pull yaoapp/tai:latest
|
||||||
|
docker pull alpine:latest
|
||||||
|
|
||||||
- name: Start Tai Server (Docker proxy for remote mode)
|
- name: Install k3d
|
||||||
|
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
|
||||||
|
|
||||||
|
- name: Create k3d cluster
|
||||||
run: |
|
run: |
|
||||||
|
k3d cluster create tai-test --no-lb --wait --api-port 16443
|
||||||
|
kubectl wait --for=condition=Ready node --all --timeout=60s
|
||||||
|
k3d image import alpine:latest -c tai-test
|
||||||
|
|
||||||
|
- name: Start Tai (Docker + K8s proxy)
|
||||||
|
run: |
|
||||||
|
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||||
|
echo "k3d server IP: ${K3D_IP}"
|
||||||
|
|
||||||
docker run -d --name tai \
|
docker run -d --name tai \
|
||||||
|
--network k3d-tai-test \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
|
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
|
||||||
|
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
||||||
yaoapp/tai:latest
|
yaoapp/tai:latest
|
||||||
|
|
||||||
|
TAI_HTTP_READY=false
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
||||||
echo "Tai HTTP is ready"
|
echo "Tai HTTP is ready"
|
||||||
|
TAI_HTTP_READY=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai HTTP... ($i)"
|
echo "Waiting for Tai HTTP... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
if [ "$TAI_HTTP_READY" != "true" ]; then
|
||||||
|
echo "::error::Tai HTTP failed to become ready within 30s"
|
||||||
|
docker logs tai 2>&1 || true
|
||||||
|
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAI_GRPC_READY=false
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
||||||
echo "Tai gRPC is ready"
|
echo "Tai gRPC is ready"
|
||||||
|
TAI_GRPC_READY=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai gRPC... ($i)"
|
echo "Waiting for Tai gRPC... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
if [ "$TAI_GRPC_READY" != "true" ]; then
|
||||||
|
echo "::error::Tai gRPC failed to become ready within 15s"
|
||||||
|
docker logs tai 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: "Run Sandbox V2 Tests (dual-mode: local + remote)"
|
- name: Generate kubeconfig for Tai K8s proxy
|
||||||
|
run: |
|
||||||
|
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
||||||
|
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
|
||||||
|
> ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
echo "Generated kubeconfig:"
|
||||||
|
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
|
||||||
|
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
|
||||||
env:
|
env:
|
||||||
SANDBOX_TEST_IMAGE: yaoapp/sandbox-v2-test:latest
|
TAI_TEST_HOST: "127.0.0.1"
|
||||||
|
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
|
||||||
|
TAI_TEST_K8S_HOST: "127.0.0.1"
|
||||||
|
TAI_TEST_K8S_PORT: "6443"
|
||||||
|
TAI_TEST_GRPC_PORT: "9100"
|
||||||
|
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
|
||||||
|
TAI_TEST_HOST_IP: "172.17.0.1"
|
||||||
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
||||||
|
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||||
run: make unit-test-sandbox-v2
|
run: make unit-test-sandbox-v2
|
||||||
|
|
||||||
- name: Codecov Report
|
- name: Codecov Report
|
||||||
|
|
@ -1087,8 +1156,6 @@ jobs:
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
files: sandbox/v2/coverage.out
|
|
||||||
flags: sandbox-v2
|
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
|
|
||||||
- name: "Comment on PR - Sandbox V2 Tests Done"
|
- name: "Comment on PR - Sandbox V2 Tests Done"
|
||||||
|
|
@ -1102,7 +1169,7 @@ jobs:
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: issue_number,
|
issue_number: issue_number,
|
||||||
body: '✅ Sandbox V2 Tests passed!'
|
body: '✅ Sandbox V2 Tests passed (tai + sandbox-v2 + workspace)!'
|
||||||
});
|
});
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -1715,20 +1782,10 @@ jobs:
|
||||||
});
|
});
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Tai SDK Tests (requires Tai container with Docker socket mount)
|
# Benchmark: Sandbox V2 + Workspace (parallel with SandboxV2Test, non-blocking)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
TaiTest:
|
BenchmarkSandboxV2:
|
||||||
runs-on: ubuntu-latest
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go: ["1.25"]
|
go: ["1.25"]
|
||||||
|
|
@ -1765,20 +1822,6 @@ jobs:
|
||||||
echo HEAD=$(cat SHA) >> $GITHUB_ENV
|
echo HEAD=$(cat SHA) >> $GITHUB_ENV
|
||||||
echo NR=$(cat NR) >> $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
|
- name: Checkout Kun
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -1861,105 +1904,46 @@ jobs:
|
||||||
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
||||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Pull Tai & Test Images
|
- name: Pull Test Images
|
||||||
run: |
|
run: |
|
||||||
|
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||||
docker pull yaoapp/tai:latest
|
docker pull yaoapp/tai:latest
|
||||||
docker pull alpine:latest
|
docker pull alpine:latest
|
||||||
|
|
||||||
- name: Install k3d
|
- name: Start Tai (Docker proxy for benchmarks)
|
||||||
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
|
|
||||||
|
|
||||||
- name: Create k3d cluster
|
|
||||||
run: |
|
run: |
|
||||||
k3d cluster create tai-test --no-lb --wait --api-port 16443
|
|
||||||
kubectl wait --for=condition=Ready node --all --timeout=60s
|
|
||||||
k3d image import alpine:latest -c tai-test
|
|
||||||
|
|
||||||
- name: Start Tai (with Docker socket + K8s proxy)
|
|
||||||
run: |
|
|
||||||
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
|
||||||
echo "k3d server IP: ${K3D_IP}"
|
|
||||||
|
|
||||||
docker run -d --name tai \
|
docker run -d --name tai \
|
||||||
--network k3d-tai-test \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-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 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
|
||||||
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
|
||||||
yaoapp/tai:latest
|
yaoapp/tai:latest
|
||||||
|
|
||||||
TAI_HTTP_READY=false
|
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
||||||
echo "Tai HTTP is ready"
|
echo "Tai HTTP is ready"
|
||||||
TAI_HTTP_READY=true
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai HTTP... ($i)"
|
echo "Waiting for Tai HTTP... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
if [ "$TAI_HTTP_READY" != "true" ]; then
|
|
||||||
echo "::error::Tai HTTP failed to become ready within 30s"
|
|
||||||
echo "--- Tai container logs ---"
|
|
||||||
docker logs tai 2>&1 || true
|
|
||||||
echo "--- Tai container status ---"
|
|
||||||
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAI_GRPC_READY=false
|
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
||||||
echo "Tai gRPC is ready"
|
echo "Tai gRPC is ready"
|
||||||
TAI_GRPC_READY=true
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai gRPC... ($i)"
|
echo "Waiting for Tai gRPC... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
if [ "$TAI_GRPC_READY" != "true" ]; then
|
|
||||||
echo "::error::Tai gRPC failed to become ready within 15s"
|
|
||||||
echo "--- Tai container logs ---"
|
|
||||||
docker logs tai 2>&1 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate kubeconfig for Tai K8s proxy
|
- name: Run Benchmarks
|
||||||
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
|
|
||||||
env:
|
env:
|
||||||
TAI_TEST_HOST: "127.0.0.1"
|
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_DOCKER: "tcp://127.0.0.1:2375"
|
||||||
TAI_TEST_K8S_HOST: "127.0.0.1"
|
TAI_TEST_GRPC_PORT: "9100"
|
||||||
TAI_TEST_K8S_PORT: "6443"
|
|
||||||
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
|
|
||||||
TAI_TEST_HOST_IP: "172.17.0.1"
|
TAI_TEST_HOST_IP: "172.17.0.1"
|
||||||
run: make unit-test-tai
|
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
||||||
|
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||||
- name: Codecov Report
|
run: make benchmark-sandbox-v2
|
||||||
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!'
|
|
||||||
});
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)
|
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)
|
||||||
|
|
|
||||||
158
.github/workflows/unit-test.yml
vendored
158
.github/workflows/unit-test.yml
vendored
|
|
@ -680,10 +680,20 @@ jobs:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Sandbox V2 Tests (requires Docker + Tai for dual-mode)
|
# Sandbox V2 Tests (tai + sandbox/v2 + workspace, Docker + K8s via k3d)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
sandbox-v2-test:
|
sandbox-v2-test:
|
||||||
runs-on: ubuntu-latest
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go: ["1.25"]
|
go: ["1.25"]
|
||||||
|
|
@ -746,48 +756,107 @@ jobs:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
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 }}
|
- name: Setup Go ${{ matrix.go }}
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go }}
|
go-version: ${{ matrix.go }}
|
||||||
|
|
||||||
|
- name: Start Redis
|
||||||
|
run: docker run --name redis --publish 6379:6379 --detach redis:6
|
||||||
|
|
||||||
- name: Setup Go Tools
|
- name: Setup Go Tools
|
||||||
run: make 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
|
- name: Pull Test Images
|
||||||
run: |
|
run: |
|
||||||
docker pull yaoapp/sandbox-v2-test:latest || true
|
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||||
docker pull yaoapp/tai:latest
|
docker pull yaoapp/tai:latest
|
||||||
|
docker pull alpine:latest
|
||||||
|
|
||||||
- name: Start Tai Server (Docker proxy for remote mode)
|
- name: Install k3d
|
||||||
|
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
|
||||||
|
|
||||||
|
- name: Create k3d cluster
|
||||||
run: |
|
run: |
|
||||||
|
k3d cluster create tai-test --no-lb --wait --api-port 16443
|
||||||
|
kubectl wait --for=condition=Ready node --all --timeout=60s
|
||||||
|
k3d image import alpine:latest -c tai-test
|
||||||
|
|
||||||
|
- name: Start Tai (Docker + K8s proxy)
|
||||||
|
run: |
|
||||||
|
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||||
|
echo "k3d server IP: ${K3D_IP}"
|
||||||
|
|
||||||
docker run -d --name tai \
|
docker run -d --name tai \
|
||||||
|
--network k3d-tai-test \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
|
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
|
||||||
|
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
||||||
yaoapp/tai:latest
|
yaoapp/tai:latest
|
||||||
|
|
||||||
|
TAI_HTTP_READY=false
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
||||||
echo "Tai HTTP is ready"
|
echo "Tai HTTP is ready"
|
||||||
|
TAI_HTTP_READY=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai HTTP... ($i)"
|
echo "Waiting for Tai HTTP... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
if [ "$TAI_HTTP_READY" != "true" ]; then
|
||||||
|
echo "::error::Tai HTTP failed to become ready within 30s"
|
||||||
|
docker logs tai 2>&1 || true
|
||||||
|
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAI_GRPC_READY=false
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
||||||
echo "Tai gRPC is ready"
|
echo "Tai gRPC is ready"
|
||||||
|
TAI_GRPC_READY=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai gRPC... ($i)"
|
echo "Waiting for Tai gRPC... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
if [ "$TAI_GRPC_READY" != "true" ]; then
|
||||||
|
echo "::error::Tai gRPC failed to become ready within 15s"
|
||||||
|
docker logs tai 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: "Run Sandbox V2 Tests (dual-mode: local + remote)"
|
- name: Generate kubeconfig for Tai K8s proxy
|
||||||
|
run: |
|
||||||
|
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
||||||
|
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
|
||||||
|
> ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
echo "Generated kubeconfig:"
|
||||||
|
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
|
||||||
|
|
||||||
|
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
|
||||||
env:
|
env:
|
||||||
SANDBOX_TEST_IMAGE: yaoapp/sandbox-v2-test:latest
|
TAI_TEST_HOST: "127.0.0.1"
|
||||||
|
TAI_TEST_DOCKER: "tcp://127.0.0.1:2375"
|
||||||
|
TAI_TEST_K8S_HOST: "127.0.0.1"
|
||||||
|
TAI_TEST_K8S_PORT: "6443"
|
||||||
|
TAI_TEST_GRPC_PORT: "9100"
|
||||||
|
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
|
||||||
|
TAI_TEST_HOST_IP: "172.17.0.1"
|
||||||
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
||||||
|
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||||
run: make unit-test-sandbox-v2
|
run: make unit-test-sandbox-v2
|
||||||
|
|
||||||
- name: Codecov Report
|
- name: Codecov Report
|
||||||
|
|
@ -795,8 +864,6 @@ jobs:
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
files: sandbox/v2/coverage.out
|
|
||||||
flags: sandbox-v2
|
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -1255,20 +1322,10 @@ jobs:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
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
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go: ["1.25"]
|
go: ["1.25"]
|
||||||
|
|
@ -1353,91 +1410,46 @@ jobs:
|
||||||
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
||||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Pull Tai & Test Images
|
- name: Pull Test Images
|
||||||
run: |
|
run: |
|
||||||
|
docker pull yaoapp/sandbox-v2-test:latest || true
|
||||||
docker pull yaoapp/tai:latest
|
docker pull yaoapp/tai:latest
|
||||||
docker pull alpine:latest
|
docker pull alpine:latest
|
||||||
|
|
||||||
- name: Install k3d
|
- name: Start Tai (Docker proxy for benchmarks)
|
||||||
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
|
|
||||||
|
|
||||||
- name: Create k3d cluster
|
|
||||||
run: |
|
run: |
|
||||||
k3d cluster create tai-test --no-lb --wait --api-port 16443
|
|
||||||
kubectl wait --for=condition=Ready node --all --timeout=60s
|
|
||||||
k3d image import alpine:latest -c tai-test
|
|
||||||
|
|
||||||
- name: Start Tai (with Docker socket + K8s proxy)
|
|
||||||
run: |
|
|
||||||
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
|
||||||
echo "k3d server IP: ${K3D_IP}"
|
|
||||||
|
|
||||||
docker run -d --name tai \
|
docker run -d --name tai \
|
||||||
--network k3d-tai-test \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-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 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \
|
||||||
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
|
|
||||||
yaoapp/tai:latest
|
yaoapp/tai:latest
|
||||||
|
|
||||||
TAI_HTTP_READY=false
|
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
|
||||||
echo "Tai HTTP is ready"
|
echo "Tai HTTP is ready"
|
||||||
TAI_HTTP_READY=true
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai HTTP... ($i)"
|
echo "Waiting for Tai HTTP... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
if [ "$TAI_HTTP_READY" != "true" ]; then
|
|
||||||
echo "::error::Tai HTTP failed to become ready within 30s"
|
|
||||||
echo "--- Tai container logs ---"
|
|
||||||
docker logs tai 2>&1 || true
|
|
||||||
echo "--- Tai container status ---"
|
|
||||||
docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAI_GRPC_READY=false
|
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
if nc -z 127.0.0.1 9100 2>/dev/null; then
|
||||||
echo "Tai gRPC is ready"
|
echo "Tai gRPC is ready"
|
||||||
TAI_GRPC_READY=true
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Waiting for Tai gRPC... ($i)"
|
echo "Waiting for Tai gRPC... ($i)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
if [ "$TAI_GRPC_READY" != "true" ]; then
|
|
||||||
echo "::error::Tai gRPC failed to become ready within 15s"
|
|
||||||
echo "--- Tai container logs ---"
|
|
||||||
docker logs tai 2>&1 || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate kubeconfig for Tai K8s proxy
|
- name: Run Benchmarks
|
||||||
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
|
|
||||||
env:
|
env:
|
||||||
TAI_TEST_HOST: "127.0.0.1"
|
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_DOCKER: "tcp://127.0.0.1:2375"
|
||||||
TAI_TEST_K8S_HOST: "127.0.0.1"
|
TAI_TEST_GRPC_PORT: "9100"
|
||||||
TAI_TEST_K8S_PORT: "6443"
|
|
||||||
TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml"
|
|
||||||
TAI_TEST_HOST_IP: "172.17.0.1"
|
TAI_TEST_HOST_IP: "172.17.0.1"
|
||||||
run: make unit-test-tai
|
SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100"
|
||||||
|
SANDBOX_TEST_IMAGE: "yaoapp/sandbox-v2-test:latest"
|
||||||
- name: Codecov Report
|
run: make benchmark-sandbox-v2
|
||||||
uses: codecov/codecov-action@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)
|
# gRPC Tests - Run once with SQLite (transport layer, no DB matrix needed)
|
||||||
|
|
|
||||||
92
Makefile
92
Makefile
|
|
@ -23,6 +23,8 @@ TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot
|
||||||
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2')
|
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2')
|
||||||
# Tai SDK tests (requires Tai container with Docker socket)
|
# Tai SDK tests (requires Tai container with Docker socket)
|
||||||
TESTFOLDER_TAI := $(shell $(GO) list ./tai/...)
|
TESTFOLDER_TAI := $(shell $(GO) list ./tai/...)
|
||||||
|
# Workspace tests (requires Tai for remote mode)
|
||||||
|
TESTFOLDER_WORKSPACE := $(shell $(GO) list ./workspace/...)
|
||||||
# gRPC tests
|
# gRPC tests
|
||||||
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
|
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
|
||||||
TESTTAGS ?= ""
|
TESTTAGS ?= ""
|
||||||
|
|
@ -199,16 +201,95 @@ unit-test-registry:
|
||||||
rm profile.out; \
|
rm profile.out; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Sandbox V2 Unit Test (requires Docker; optionally Tai for remote mode)
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sandbox V2 Integration Test (tai + sandbox/v2 + workspace)
|
||||||
|
# Requires: Docker, Tai container, optionally k3d for K8s mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest
|
||||||
|
|
||||||
.PHONY: unit-test-sandbox-v2
|
.PHONY: unit-test-sandbox-v2
|
||||||
unit-test-sandbox-v2:
|
unit-test-sandbox-v2: unit-test-sandbox-v2-pull unit-test-tai unit-test-sandbox-v2-core unit-test-workspace
|
||||||
|
@echo ""
|
||||||
|
@echo "============================================="
|
||||||
|
@echo "All Sandbox V2 integration tests passed"
|
||||||
|
@echo "============================================="
|
||||||
|
|
||||||
|
.PHONY: unit-test-sandbox-v2-pull
|
||||||
|
unit-test-sandbox-v2-pull:
|
||||||
|
@echo ""
|
||||||
|
@echo "============================================="
|
||||||
|
@echo "Pulling test images..."
|
||||||
|
@echo "============================================="
|
||||||
|
docker pull $(SANDBOX_V2_IMAGE) || true
|
||||||
|
docker pull alpine:latest || true
|
||||||
|
|
||||||
|
.PHONY: unit-test-sandbox-v2-core
|
||||||
|
unit-test-sandbox-v2-core:
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@echo "Running Sandbox V2 Tests..."
|
@echo "Running Sandbox V2 Tests..."
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
docker pull $(SANDBOX_V2_IMAGE) || true
|
|
||||||
$(MAKE) -C sandbox/v2 test-ci TEST_IMAGE=$(SANDBOX_V2_IMAGE)
|
$(MAKE) -C sandbox/v2 test-ci TEST_IMAGE=$(SANDBOX_V2_IMAGE)
|
||||||
SANDBOX_V2_IMAGE ?= yaoapp/sandbox-v2-test:latest
|
|
||||||
|
# Workspace Unit Test (requires Tai for remote mode)
|
||||||
|
.PHONY: unit-test-workspace
|
||||||
|
unit-test-workspace:
|
||||||
|
@echo ""
|
||||||
|
@echo "============================================="
|
||||||
|
@echo "Running Workspace Tests..."
|
||||||
|
@echo "============================================="
|
||||||
|
echo "mode: count" > coverage.out
|
||||||
|
for d in $(TESTFOLDER_WORKSPACE); do \
|
||||||
|
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
|
||||||
|
cat tmp.out; \
|
||||||
|
if grep -q "^--- FAIL" tmp.out; then \
|
||||||
|
rm tmp.out; \
|
||||||
|
exit 1; \
|
||||||
|
elif grep -q "^FAIL" tmp.out; then \
|
||||||
|
rm tmp.out; \
|
||||||
|
exit 1; \
|
||||||
|
elif grep -q "^panic:" tmp.out; then \
|
||||||
|
rm tmp.out; \
|
||||||
|
exit 1; \
|
||||||
|
elif grep -q "build failed" tmp.out; then \
|
||||||
|
rm tmp.out; \
|
||||||
|
exit 1; \
|
||||||
|
elif grep -q "setup failed" tmp.out; then \
|
||||||
|
rm tmp.out; \
|
||||||
|
exit 1; \
|
||||||
|
elif grep -q "runtime error" tmp.out; then \
|
||||||
|
rm tmp.out; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
if [ -f profile.out ]; then \
|
||||||
|
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||||
|
rm profile.out; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
@echo ""
|
||||||
|
@echo "============================================="
|
||||||
|
@echo "All workspace tests passed"
|
||||||
|
@echo "============================================="
|
||||||
|
|
||||||
|
# Benchmark: Sandbox V2 + Workspace
|
||||||
|
.PHONY: benchmark-sandbox-v2
|
||||||
|
benchmark-sandbox-v2:
|
||||||
|
@echo ""
|
||||||
|
@echo "============================================="
|
||||||
|
@echo "Running Sandbox V2 + Workspace Benchmarks..."
|
||||||
|
@echo "============================================="
|
||||||
|
@for d in $$($(GO) list ./sandbox/v2/... ./workspace/...); do \
|
||||||
|
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
|
||||||
|
echo ""; \
|
||||||
|
echo "Benchmarking: $$d"; \
|
||||||
|
echo "---------------------------------------------"; \
|
||||||
|
$(GO) test -bench=. -benchmem -benchtime=1x -run='^$$' -timeout=600s $$d || true; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
@echo ""
|
||||||
|
@echo "============================================="
|
||||||
|
@echo "All benchmarks completed"
|
||||||
|
@echo "============================================="
|
||||||
|
|
||||||
# Sandbox Unit Test (requires Docker)
|
# Sandbox Unit Test (requires Docker)
|
||||||
.PHONY: unit-test-sandbox
|
.PHONY: unit-test-sandbox
|
||||||
|
|
@ -262,9 +343,6 @@ unit-test-tai:
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@echo "Running Tai SDK Tests (requires Tai container)..."
|
@echo "Running Tai SDK Tests (requires Tai container)..."
|
||||||
@echo "============================================="
|
@echo "============================================="
|
||||||
@echo "Pulling test images..."
|
|
||||||
docker pull alpine:latest || true
|
|
||||||
@echo ""
|
|
||||||
echo "mode: count" > coverage.out
|
echo "mode: count" > coverage.out
|
||||||
for d in $(TESTFOLDER_TAI); do \
|
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; \
|
$(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
|
||||||
|
|
|
||||||
|
|
@ -1126,330 +1126,31 @@ Everything in the current `sandbox/` that is replaced by tai:
|
||||||
| **Multi-node** | Local only | Local + Remote via Tai |
|
| **Multi-node** | Local only | Local + Remote via Tai |
|
||||||
| **K8s** | Not supported | Supported via tai.Client |
|
| **K8s** | Not supported | Supported via tai.Client |
|
||||||
|
|
||||||
## Workspace — First-Class Entity
|
## Workspace
|
||||||
|
|
||||||
### Problem
|
Workspace is now a **top-level module** (`workspace/`), parallel to `sandbox/v2`.
|
||||||
|
|
||||||
Current design: `Box.Workspace()` returns `workspace.FS` keyed by `box.id` — workspace and container are 1:1, same lifecycle. This couples file storage to container lifetime.
|
See [`workspace/DESIGN.md`](../workspace/DESIGN.md) for the full design document covering:
|
||||||
|
- Workspace as a first-class, persistent entity decoupled from containers
|
||||||
|
- Node binding and container scheduling
|
||||||
|
- Workspace CRUD and file I/O APIs
|
||||||
|
- Integration with Sandbox `CreateOptions`
|
||||||
|
- Metadata storage strategy
|
||||||
|
- Process and JSAPI registration
|
||||||
|
- Implementation plan
|
||||||
|
|
||||||
Real usage pattern:
|
### Integration point
|
||||||
|
|
||||||
```
|
`sandbox/v2` integrates with Workspace via `CreateOptions.WorkspaceID`:
|
||||||
User creates a project → uploads files → works on it across multiple chat sessions
|
|
||||||
→ attaches a long-running dev server → destroys/rebuilds containers freely
|
|
||||||
→ project files must survive all of this
|
|
||||||
```
|
|
||||||
|
|
||||||
Workspace must outlive containers. It is the persistent artifact; containers are disposable compute.
|
|
||||||
|
|
||||||
### Design
|
|
||||||
|
|
||||||
Workspace becomes an independent entity with its own CRUD, decoupled from both Chat sessions and containers.
|
|
||||||
|
|
||||||
```
|
|
||||||
Workspace (persistent, user-managed)
|
|
||||||
├── CRUD / file management UI
|
|
||||||
├── Mountable to 0~N containers simultaneously
|
|
||||||
└── Referenced by 0~N Chat sessions
|
|
||||||
|
|
||||||
Chat Session
|
|
||||||
└── Selects a Workspace (not a container)
|
|
||||||
|
|
||||||
Container (ephemeral compute)
|
|
||||||
├── Bind-mounts a Workspace to /workspace
|
|
||||||
├── rw or ro per mount
|
|
||||||
└── Created/destroyed independently of Workspace
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workspace struct
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Workspace struct {
|
|
||||||
ID string // unique identifier, e.g. "ws-abc123"
|
|
||||||
Name string // human-readable, e.g. "my-react-app"
|
|
||||||
Owner string // user ID
|
|
||||||
Labels map[string]string // arbitrary metadata
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No container references stored here. Workspace is pure storage — it doesn't know or care about containers.
|
|
||||||
|
|
||||||
### MountMode
|
|
||||||
|
|
||||||
```go
|
|
||||||
type MountMode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MountRW MountMode = "rw" // read-write (default)
|
|
||||||
MountRO MountMode = "ro" // read-only
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- A Workspace can be mounted by multiple containers simultaneously
|
|
||||||
- Each mount independently specifies `rw` or `ro`
|
|
||||||
- No write-lock enforcement — caller manages concurrency
|
|
||||||
- Default is `rw`
|
|
||||||
|
|
||||||
Rationale: In practice, Chat containers write source code and Runtime containers write build artifacts/logs — different files, no real conflict. Enforcing locks adds complexity without solving a real problem in this use case.
|
|
||||||
|
|
||||||
### CreateOptions changes
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type CreateOptions struct {
|
type CreateOptions struct {
|
||||||
// ... existing fields ...
|
// ... existing fields ...
|
||||||
|
|
||||||
// Workspace mount (new)
|
|
||||||
WorkspaceID string // workspace to mount; empty = no workspace
|
WorkspaceID string // workspace to mount; empty = no workspace
|
||||||
MountMode MountMode // "rw" (default) or "ro"
|
MountMode MountMode // "rw" (default) or "ro"
|
||||||
MountPath string // container path; default "/workspace"
|
MountPath string // container path; default "/workspace"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When `WorkspaceID` is set, Manager resolves the storage path via `VolumeProvider.MountSpec()` and injects the bind mount into the container create options.
|
When `WorkspaceID` is set, the Sandbox Manager resolves the Workspace's bound node and forces the container to be created on that node. See `workspace/DESIGN.md` for full details.
|
||||||
|
|
||||||
### Manager API additions
|
|
||||||
|
|
||||||
```go
|
|
||||||
// --- Workspace CRUD ---
|
|
||||||
|
|
||||||
// CreateWorkspace creates a persistent workspace.
|
|
||||||
// Storage is allocated via VolumeProvider.ResolvePath().
|
|
||||||
func (m *Manager) CreateWorkspace(ctx context.Context, opts WorkspaceOptions) (*Workspace, error)
|
|
||||||
|
|
||||||
// GetWorkspace returns a workspace by ID.
|
|
||||||
func (m *Manager) GetWorkspace(ctx context.Context, id string) (*Workspace, error)
|
|
||||||
|
|
||||||
// ListWorkspaces returns workspaces, optionally filtered by owner.
|
|
||||||
func (m *Manager) ListWorkspaces(ctx context.Context, opts WorkspaceListOptions) ([]*Workspace, error)
|
|
||||||
|
|
||||||
// DeleteWorkspace removes workspace storage.
|
|
||||||
// Fails if any containers currently mount it (unless force=true).
|
|
||||||
func (m *Manager) DeleteWorkspace(ctx context.Context, id string, force bool) error
|
|
||||||
|
|
||||||
type WorkspaceOptions struct {
|
|
||||||
ID string // explicit ID; empty = auto-generate
|
|
||||||
Name string // human-readable name
|
|
||||||
Owner string
|
|
||||||
Labels map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceListOptions struct {
|
|
||||||
Owner string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Container creation flow (updated)
|
|
||||||
|
|
||||||
```
|
|
||||||
Manager.Create(ctx, CreateOptions{
|
|
||||||
Image: "yaoapp/workspace:latest",
|
|
||||||
WorkspaceID: "ws-abc123", // ← new
|
|
||||||
MountMode: MountRW, // ← new
|
|
||||||
})
|
|
||||||
|
|
||||||
1. Validate CreateOptions (image required, etc.)
|
|
||||||
2. If WorkspaceID set:
|
|
||||||
a. Verify workspace exists
|
|
||||||
b. spec := provider.MountSpec(workspaceID)
|
|
||||||
c. Inject into tai CreateOptions:
|
|
||||||
- Docker: opts.Binds = ["/data/ws/ws-abc123:/workspace:rw"]
|
|
||||||
- K8s: opts.Volumes + opts.VolumeMounts (PVC)
|
|
||||||
3. Create container via tai.Client.Sandbox().Create()
|
|
||||||
4. Start container
|
|
||||||
5. Return Box
|
|
||||||
```
|
|
||||||
|
|
||||||
### Box.Workspace() behavior change
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (b *Box) Workspace() workspace.FS {
|
|
||||||
// If container has a workspace mounted, use the workspace ID as session.
|
|
||||||
// Otherwise fall back to box ID (backward compatible).
|
|
||||||
sessionID := b.workspaceID
|
|
||||||
if sessionID == "" {
|
|
||||||
sessionID = b.id
|
|
||||||
}
|
|
||||||
client, _ := b.manager.getPool(b.pool)
|
|
||||||
return client.Workspace(sessionID)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple boxes mounting the same workspace → same `sessionID` → same files via Volume API.
|
|
||||||
|
|
||||||
### Typical flows
|
|
||||||
|
|
||||||
**Flow 1: Workspace management UI**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User creates workspace "my-project"
|
|
||||||
→ Manager.CreateWorkspace(opts) → VolumeProvider.ResolvePath("ws-123")
|
|
||||||
→ Directory /data/ws/ws-123/ created
|
|
||||||
|
|
||||||
2. User uploads files via Workspace management UI
|
|
||||||
→ Volume.WriteFile(ctx, "ws-123", "src/main.go", data, 0644)
|
|
||||||
→ Files written to /data/ws/ws-123/src/main.go
|
|
||||||
|
|
||||||
3. User browses files
|
|
||||||
→ Volume.ListDir(ctx, "ws-123", "src/")
|
|
||||||
→ Returns file listing from /data/ws/ws-123/src/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow 2: Chat with Workspace**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User opens Chat, selects workspace "my-project" (ws-123)
|
|
||||||
|
|
||||||
2. Agent needs a container:
|
|
||||||
→ Manager.Create(ctx, CreateOptions{
|
|
||||||
Image: "yaoapp/workspace:latest",
|
|
||||||
WorkspaceID: "ws-123",
|
|
||||||
MountMode: MountRW,
|
|
||||||
})
|
|
||||||
→ Container starts with -v /data/ws/ws-123:/workspace:rw
|
|
||||||
→ Agent can exec "ls /workspace/src/" inside container
|
|
||||||
|
|
||||||
3. Chat ends, container destroyed
|
|
||||||
→ Workspace files persist in /data/ws/ws-123/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow 3: Long-running Runtime**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User starts Runtime container for workspace "my-project":
|
|
||||||
→ Manager.Create(ctx, CreateOptions{
|
|
||||||
Image: "node:20",
|
|
||||||
WorkspaceID: "ws-123",
|
|
||||||
MountMode: MountRW,
|
|
||||||
Policy: Persistent,
|
|
||||||
Ports: [{ContainerPort: 3000}],
|
|
||||||
})
|
|
||||||
→ Container starts with -v /data/ws/ws-123:/workspace:rw
|
|
||||||
→ Inside container: cd /workspace && npm install && npm run dev
|
|
||||||
|
|
||||||
2. User accesses dev server:
|
|
||||||
→ box.Proxy(ctx, 3000, "/") → "http://localhost:32768/"
|
|
||||||
→ Or box.VNC(ctx) for desktop preview
|
|
||||||
|
|
||||||
3. User opens Chat, selects same workspace:
|
|
||||||
→ Manager.Create(ctx, CreateOptions{
|
|
||||||
Image: "yaoapp/agent:latest",
|
|
||||||
WorkspaceID: "ws-123",
|
|
||||||
MountMode: MountRW,
|
|
||||||
})
|
|
||||||
→ Second container, same workspace mounted
|
|
||||||
→ Agent modifies source → Runtime hot-reloads
|
|
||||||
|
|
||||||
4. Chat ends, Chat container destroyed
|
|
||||||
→ Runtime container keeps running
|
|
||||||
→ Workspace files persist
|
|
||||||
```
|
|
||||||
|
|
||||||
### Storage backend (already implemented in Tai)
|
|
||||||
|
|
||||||
The `storage.VolumeProvider` interface in Tai Server already has three complete implementations:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// tai/storage/provider.go
|
|
||||||
type VolumeProvider interface {
|
|
||||||
ResolvePath(sessionID string) (string, error)
|
|
||||||
MountSpec(sessionID string) MountConfig
|
|
||||||
Cleanup(sessionID string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type MountConfig struct {
|
|
||||||
Type string // "bind" | "volume" | "pvc"
|
|
||||||
Source string
|
|
||||||
Target string // always /workspace
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Provider | Backend | MountSpec | Status |
|
|
||||||
|----------|---------|-----------|--------|
|
|
||||||
| `BindMountProvider` | Host directory (`/data/ws/{id}/`) | `type:"bind"` | Implemented, default |
|
|
||||||
| `DockerVolumeProvider` | Docker named volume (`tai-{id}`) | `type:"volume"` | Implemented |
|
|
||||||
| `K8sPVCProvider` | K8s PVC (`tai-{id}-pvc`, 10Gi RWO) | `type:"pvc"` | Implemented |
|
|
||||||
|
|
||||||
These are implemented but **not yet wired** into the container creation flow. The only work needed is calling `MountSpec()` during `Manager.Create()` and passing the result into `tai.sandbox.CreateOptions.Binds`.
|
|
||||||
|
|
||||||
For file operations (CRUD UI), Tai's `Volume` gRPC service already operates on the same `dataDir/{sessionID}/` paths. No additional work needed — `Volume.ReadFile("ws-123", "src/main.go")` reads from the same directory that gets bind-mounted into containers.
|
|
||||||
|
|
||||||
### Workspace metadata storage
|
|
||||||
|
|
||||||
Workspace metadata (ID, Name, Owner, Labels, timestamps) needs persistent storage.
|
|
||||||
|
|
||||||
Recommendation: **JSON file** (`/data/ws/{id}/.workspace.json`) for Phase 1. Each workspace directory contains its own metadata. Listing = scan directories + read metadata files. Zero dependencies, works everywhere.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "ws-abc123",
|
|
||||||
"name": "my-react-app",
|
|
||||||
"owner": "user-001",
|
|
||||||
"labels": {"project": "frontend"},
|
|
||||||
"created_at": "2026-03-05T10:00:00Z",
|
|
||||||
"updated_at": "2026-03-05T12:30:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Can migrate to SQLite or Yao DB later if query/filter requirements grow.
|
|
||||||
|
|
||||||
### Process registration additions
|
|
||||||
|
|
||||||
| Process | Args | Returns |
|
|
||||||
|---------|------|---------|
|
|
||||||
| `sandbox.workspace.Create` | `options` (WorkspaceOptions JSON) | Workspace |
|
|
||||||
| `sandbox.workspace.Get` | `id` | Workspace |
|
|
||||||
| `sandbox.workspace.List` | `options` (WorkspaceListOptions JSON) | []Workspace |
|
|
||||||
| `sandbox.workspace.Delete` | `id`, `force?` | — |
|
|
||||||
|
|
||||||
### JSAPI additions
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Workspace CRUD
|
|
||||||
var ws = Sandbox.CreateWorkspace({ name: "my-project", owner: "user-001" })
|
|
||||||
var ws = Sandbox.GetWorkspace("ws-abc123")
|
|
||||||
var list = Sandbox.ListWorkspaces({ owner: "user-001" })
|
|
||||||
Sandbox.DeleteWorkspace("ws-abc123")
|
|
||||||
|
|
||||||
// File operations on workspace (without a container)
|
|
||||||
ws.ReadFile("src/main.go")
|
|
||||||
ws.WriteFile("src/main.go", "package main\n...")
|
|
||||||
ws.ListDir("src/")
|
|
||||||
ws.Remove("tmp.txt")
|
|
||||||
|
|
||||||
// Create container with workspace
|
|
||||||
var sb = Sandbox("my-box", {
|
|
||||||
image: "node:20",
|
|
||||||
workspace_id: ws.id,
|
|
||||||
mount_mode: "rw",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### What changes from current design
|
|
||||||
|
|
||||||
| Aspect | Before | After |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| Workspace lifecycle | Tied to Box (same ID, same lifetime) | Independent entity, outlives containers |
|
|
||||||
| Workspace identity | `sessionID = box.id` | `sessionID = workspace.id` (explicit) |
|
|
||||||
| Container ↔ Workspace | 1:1, implicit | N:1, explicit via `CreateOptions.WorkspaceID` |
|
|
||||||
| File persistence | Lost when container removed | Persists until workspace deleted |
|
|
||||||
| Multi-container access | Not possible | Multiple containers mount same workspace |
|
|
||||||
| Storage backend | Volume gRPC only (no mount) | Volume gRPC + bind mount into container |
|
|
||||||
| CRUD without container | Not possible | Via Volume API directly |
|
|
||||||
|
|
||||||
### Implementation plan
|
|
||||||
|
|
||||||
**Phase 1.5** (between current Phase 1 and Phase 2):
|
|
||||||
|
|
||||||
| Task | Detail |
|
|
||||||
|------|--------|
|
|
||||||
| `workspace.go` | Workspace struct, WorkspaceOptions, metadata JSON read/write |
|
|
||||||
| Manager: workspace CRUD | `CreateWorkspace` / `GetWorkspace` / `ListWorkspaces` / `DeleteWorkspace` via VolumeProvider + JSON metadata |
|
|
||||||
| Manager: `Create()` updated | Wire `WorkspaceID` → `VolumeProvider.MountSpec()` → `Binds` |
|
|
||||||
| `Box.Workspace()` updated | Use `workspaceID` as sessionID when set |
|
|
||||||
| Tai Server: wire `VolumeProvider` | Call `MountSpec()` in container creation path |
|
|
||||||
| Tests | Workspace CRUD + mount verification |
|
|
||||||
|
|
||||||
No breaking changes. Containers created without `WorkspaceID` work exactly as before (`sessionID = box.id`, no bind mount).
|
|
||||||
|
|
|
||||||
254
sandbox/v2/bench_test.go
Normal file
254
sandbox/v2/bench_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -23,10 +23,12 @@ type Box struct {
|
||||||
lastHeartbeat atomic.Int64
|
lastHeartbeat atomic.Int64
|
||||||
processCount atomic.Int32
|
processCount atomic.Int32
|
||||||
idleTimeoutD time.Duration
|
idleTimeoutD time.Duration
|
||||||
|
stopTimeoutD time.Duration
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
refreshToken string
|
refreshToken string
|
||||||
vnc bool
|
vnc bool
|
||||||
image string
|
image string
|
||||||
|
workspaceID string
|
||||||
ws workspace.FS
|
ws workspace.FS
|
||||||
manager *Manager
|
manager *Manager
|
||||||
}
|
}
|
||||||
|
|
@ -143,19 +145,28 @@ func (b *Box) Attach(ctx context.Context, port int, opts ...AttachOption) (*Serv
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace returns an fs.FS-compatible filesystem for this sandbox.
|
// 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 {
|
func (b *Box) Workspace() workspace.FS {
|
||||||
b.touch()
|
b.touch()
|
||||||
if b.ws != nil {
|
if b.ws != nil {
|
||||||
return b.ws
|
return b.ws
|
||||||
}
|
}
|
||||||
|
sessionID := b.workspaceID
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = b.id
|
||||||
|
}
|
||||||
client, err := b.manager.getPool(b.pool)
|
client, err := b.manager.getPool(b.pool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
b.ws = client.Workspace(b.id)
|
b.ws = client.Workspace(sessionID)
|
||||||
return b.ws
|
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.
|
// VNC returns the VNC WebSocket URL.
|
||||||
func (b *Box) VNC(ctx context.Context) (string, error) {
|
func (b *Box) VNC(ctx context.Context) (string, error) {
|
||||||
b.touch()
|
b.touch()
|
||||||
|
|
@ -191,7 +202,7 @@ func (b *Box) Stop(ctx context.Context) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
|
return client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stops and removes the sandbox.
|
// Remove stops and removes the sandbox.
|
||||||
|
|
@ -259,3 +270,14 @@ func (b *Box) maxLifetime() time.Duration {
|
||||||
}
|
}
|
||||||
return 0
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package sandbox_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -19,12 +18,12 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
url, err := box.Proxy(ctx, port, "/")
|
proxyURL, err := box.Proxy(ctx, port, "/")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Proxy URL: %v", err)
|
t.Fatalf("Proxy URL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
host := url[len("http://"):]
|
host := proxyURL[len("http://"):]
|
||||||
if i := len(host) - 1; host[i] == '/' {
|
if i := len(host) - 1; host[i] == '/' {
|
||||||
host = host[:i]
|
host = host[:i]
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +35,7 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
deadline := time.After(timeout)
|
deadline := time.After(timeout)
|
||||||
ticker := time.NewTicker(200 * time.Millisecond)
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -44,13 +43,16 @@ func waitForPort(t *testing.T, box *sandbox.Box, port int, timeout time.Duration
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("port %d not ready within %v", port, timeout)
|
t.Fatalf("port %d not ready within %v", port, timeout)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
conn, err := net.DialTimeout("tcp", host, time.Second)
|
conn, err := net.DialTimeout("tcp", host, 2*time.Second)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
conn.Close()
|
continue
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
fmt.Printf("waiting for %s: %v\n", host, err)
|
conn.Close()
|
||||||
|
// TCP reachable — give the service process time to accept
|
||||||
|
// application-layer connections (Python ws/sse servers in CI
|
||||||
|
// may take 1-3s after the port opens before they're ready).
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -74,9 +76,17 @@ func TestAttachWS(t *testing.T) {
|
||||||
|
|
||||||
waitForPort(t, box, 9800, 30*time.Second)
|
waitForPort(t, box, 9800, 30*time.Second)
|
||||||
|
|
||||||
conn, err := box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/"))
|
var conn *sandbox.ServiceConn
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
conn, err = box.Attach(t.Context(), 9800, sandbox.WithProtocol("ws"), sandbox.WithPath("/"))
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Attach WS: %v", err)
|
t.Fatalf("Attach WS after retries: %v", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
|
@ -114,9 +124,17 @@ func TestAttachSSE(t *testing.T) {
|
||||||
|
|
||||||
waitForPort(t, box, 9801, 30*time.Second)
|
waitForPort(t, box, 9801, 30*time.Second)
|
||||||
|
|
||||||
conn, err := box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events"))
|
var conn *sandbox.ServiceConn
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
conn, err = box.Attach(t.Context(), 9801, sandbox.WithProtocol("sse"), sandbox.WithPath("/events"))
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt+1) * 500 * time.Millisecond)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Attach SSE: %v", err)
|
t.Fatalf("Attach SSE after retries: %v", err)
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
|
|
@ -181,7 +199,7 @@ func TestVNCConnect(t *testing.T) {
|
||||||
co.VNC = true
|
co.VNC = true
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
vncURL, err := box.VNC(ctx)
|
vncURL, err := box.VNC(ctx)
|
||||||
|
|
@ -196,13 +214,20 @@ func TestVNCConnect(t *testing.T) {
|
||||||
Subprotocols: []string{"binary"},
|
Subprotocols: []string{"binary"},
|
||||||
HandshakeTimeout: 10 * time.Second,
|
HandshakeTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
ws, resp, err := dialer.DialContext(ctx, vncURL, http.Header{})
|
var ws *websocket.Conn
|
||||||
if err != nil {
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
extra := ""
|
var resp *http.Response
|
||||||
if resp != nil {
|
ws, resp, err = dialer.DialContext(ctx, vncURL, http.Header{})
|
||||||
extra = fmt.Sprintf(" (status %d)", resp.StatusCode)
|
if err == nil {
|
||||||
|
break
|
||||||
}
|
}
|
||||||
t.Fatalf("VNC dial: %v%s", err, extra)
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt+1) * time.Second)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VNC dial after retries: %v", err)
|
||||||
}
|
}
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
|
|
||||||
|
|
@ -240,12 +265,16 @@ func waitForWSEndpoint(t *testing.T, wsURL string, timeout time.Duration) {
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
t.Fatalf("VNC endpoint %s not ready within %v", host, timeout)
|
t.Fatalf("VNC endpoint %s not ready within %v", host, timeout)
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
conn, err := net.DialTimeout("tcp", host, time.Second)
|
conn, err := net.DialTimeout("tcp", host, 2*time.Second)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
time.Sleep(500 * time.Millisecond)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
sandbox/v2/box_image_test.go
Normal file
121
sandbox/v2/box_image_test.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package sandbox_test
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -143,7 +144,7 @@ func TestBoxInfo(t *testing.T) {
|
||||||
if info.ID != box.ID() {
|
if info.ID != box.ID() {
|
||||||
t.Errorf("ID = %q, want %q", info.ID, box.ID())
|
t.Errorf("ID = %q, want %q", info.ID, box.ID())
|
||||||
}
|
}
|
||||||
if info.Status != "running" {
|
if s := strings.ToLower(info.Status); s != "running" {
|
||||||
t.Errorf("status = %q, want running", info.Status)
|
t.Errorf("status = %q, want running", info.Status)
|
||||||
}
|
}
|
||||||
if info.Owner != "test-user" {
|
if info.Owner != "test-user" {
|
||||||
|
|
|
||||||
284
sandbox/v2/box_workspace_test.go
Normal file
284
sandbox/v2/box_workspace_test.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu
|
||||||
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tini \
|
||||||
curl wget git ca-certificates gnupg lsb-release jq \
|
curl wget git ca-certificates gnupg lsb-release jq \
|
||||||
vim less tree \
|
vim less tree \
|
||||||
iputils-ping net-tools dnsutils telnet netcat-openbsd \
|
iputils-ping net-tools dnsutils telnet netcat-openbsd \
|
||||||
|
|
@ -34,5 +35,5 @@ COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
USER sandbox
|
USER sandbox
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|
|
||||||
|
|
@ -24,5 +24,5 @@ ENV DISPLAY=:99
|
||||||
|
|
||||||
USER sandbox
|
USER sandbox
|
||||||
EXPOSE 5900 6080
|
EXPOSE 5900 6080
|
||||||
ENTRYPOINT ["/test-entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/test-entrypoint.sh"]
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/yaoapp/yao/tai"
|
"github.com/yaoapp/yao/tai"
|
||||||
taisandbox "github.com/yaoapp/yao/tai/sandbox"
|
taisandbox "github.com/yaoapp/yao/tai/sandbox"
|
||||||
|
"github.com/yaoapp/yao/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager manages a pool of tai.Client connections and sandbox lifecycle.
|
// Manager manages a pool of tai.Client connections and sandbox lifecycle.
|
||||||
|
|
@ -20,6 +21,7 @@ type Manager struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
grpcPort int
|
grpcPort int
|
||||||
|
wsManager *workspace.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
func newManager(cfg Config) (*Manager, error) {
|
func newManager(cfg Config) (*Manager, error) {
|
||||||
|
|
@ -176,6 +178,16 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
poolName = m.defaultPool
|
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)
|
pd := m.findPoolDef(poolName)
|
||||||
if pd == nil {
|
if pd == nil {
|
||||||
return nil, ErrPoolNotFound
|
return nil, ErrPoolNotFound
|
||||||
|
|
@ -225,11 +237,13 @@ func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Box, error)
|
||||||
policy: policy,
|
policy: policy,
|
||||||
labels: opts.Labels,
|
labels: opts.Labels,
|
||||||
idleTimeoutD: opts.IdleTimeout,
|
idleTimeoutD: opts.IdleTimeout,
|
||||||
|
stopTimeoutD: opts.StopTimeout,
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
refreshToken: refresh,
|
refreshToken: refresh,
|
||||||
manager: m,
|
manager: m,
|
||||||
vnc: opts.VNC,
|
vnc: opts.VNC,
|
||||||
image: opts.Image,
|
image: opts.Image,
|
||||||
|
workspaceID: opts.WorkspaceID,
|
||||||
}
|
}
|
||||||
box.lastCall.Store(time.Now().UnixMilli())
|
box.lastCall.Store(time.Now().UnixMilli())
|
||||||
|
|
||||||
|
|
@ -280,7 +294,7 @@ func (m *Manager) List(_ context.Context, opts ListOptions) ([]*Box, error) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stops and removes a sandbox.
|
// Remove force-removes a sandbox (SIGKILL + delete).
|
||||||
func (m *Manager) Remove(ctx context.Context, id string) error {
|
func (m *Manager) Remove(ctx context.Context, id string) error {
|
||||||
v, ok := m.boxes.Load(id)
|
v, ok := m.boxes.Load(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -290,7 +304,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error {
|
||||||
|
|
||||||
client, err := m.getPool(b.pool)
|
client, err := m.getPool(b.pool)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
|
|
||||||
client.Sandbox().Remove(ctx, b.containerID, true)
|
client.Sandbox().Remove(ctx, b.containerID, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +332,7 @@ func (m *Manager) Cleanup(ctx context.Context) error {
|
||||||
case LongRunning:
|
case LongRunning:
|
||||||
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
|
if timeout := b.idleTimeout(); timeout > 0 && idle > timeout {
|
||||||
if client, err := m.getPool(b.pool); err == nil {
|
if client, err := m.getPool(b.pool); err == nil {
|
||||||
client.Sandbox().Stop(ctx, b.containerID, 10*time.Second)
|
client.Sandbox().Stop(ctx, b.containerID, b.stopTimeout())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
|
if lifetime := b.maxLifetime(); lifetime > 0 && now.Sub(b.createdAt) > lifetime {
|
||||||
|
|
@ -352,6 +365,13 @@ func (m *Manager) SetGRPCPort(port int) {
|
||||||
m.grpcPort = port
|
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) {
|
func (m *Manager) cleanupLoop(ctx context.Context) {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
@ -448,6 +468,9 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
|
||||||
"sandbox-pool": pd.Name,
|
"sandbox-pool": pd.Name,
|
||||||
"sandbox-policy": string(opts.Policy),
|
"sandbox-policy": string(opts.Policy),
|
||||||
}
|
}
|
||||||
|
if opts.WorkspaceID != "" {
|
||||||
|
labels["workspace-id"] = opts.WorkspaceID
|
||||||
|
}
|
||||||
for k, v := range opts.Labels {
|
for k, v := range opts.Labels {
|
||||||
labels[k] = v
|
labels[k] = v
|
||||||
}
|
}
|
||||||
|
|
@ -457,7 +480,7 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
|
||||||
workDir = "/workspace"
|
workDir = "/workspace"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := []string{"sleep", "infinity"}
|
cmd := []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 86400 & wait $!; done"}
|
||||||
|
|
||||||
var ports []taisandbox.PortMapping
|
var ports []taisandbox.PortMapping
|
||||||
for _, p := range opts.Ports {
|
for _, p := range opts.Ports {
|
||||||
|
|
@ -469,11 +492,29 @@ func (m *Manager) buildTaiCreateOptions(opts CreateOptions, pd *Pool, sandboxID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workspace bind mount
|
||||||
|
var binds []string
|
||||||
|
if opts.WorkspaceID != "" && m.wsManager != nil {
|
||||||
|
mountPath := opts.MountPath
|
||||||
|
if mountPath == "" {
|
||||||
|
mountPath = "/workspace"
|
||||||
|
}
|
||||||
|
mode := opts.MountMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = "rw"
|
||||||
|
}
|
||||||
|
hostPath, _ := m.wsManager.MountPath(context.Background(), opts.WorkspaceID)
|
||||||
|
if hostPath != "" {
|
||||||
|
binds = append(binds, fmt.Sprintf("%s:%s:%s", hostPath, mountPath, mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return taisandbox.CreateOptions{
|
return taisandbox.CreateOptions{
|
||||||
Name: sandboxID,
|
Name: sandboxID,
|
||||||
Image: opts.Image,
|
Image: opts.Image,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
Env: env,
|
Env: env,
|
||||||
|
Binds: binds,
|
||||||
WorkingDir: workDir,
|
WorkingDir: workDir,
|
||||||
User: opts.User,
|
User: opts.User,
|
||||||
Memory: opts.Memory,
|
Memory: opts.Memory,
|
||||||
|
|
@ -511,9 +552,65 @@ func (m *Manager) recoverBoxes(ctx context.Context, pd *Pool, client *tai.Client
|
||||||
labels: c.Labels,
|
labels: c.Labels,
|
||||||
createdAt: time.Now(),
|
createdAt: time.Now(),
|
||||||
image: c.Image,
|
image: c.Image,
|
||||||
|
workspaceID: c.Labels["workspace-id"],
|
||||||
manager: m,
|
manager: m,
|
||||||
}
|
}
|
||||||
box.lastCall.Store(time.Now().UnixMilli())
|
box.lastCall.Store(time.Now().UnixMilli())
|
||||||
m.boxes.Store(sandboxID, box)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ func TestIdleCleanup(t *testing.T) {
|
||||||
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
|
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
|
||||||
p.IdleTimeout = 1 * time.Second
|
p.IdleTimeout = 1 * time.Second
|
||||||
})
|
})
|
||||||
|
ensureTestImage(t, m, pc.Name)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
box, err := m.Create(ctx, sandbox.CreateOptions{
|
box, err := m.Create(ctx, sandbox.CreateOptions{
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ func TestRemove(t *testing.T) {
|
||||||
for _, pc := range testPools() {
|
for _, pc := range testPools() {
|
||||||
t.Run(pc.Name, func(t *testing.T) {
|
t.Run(pc.Name, func(t *testing.T) {
|
||||||
m := setupManagerForPool(t, pc)
|
m := setupManagerForPool(t, pc)
|
||||||
|
ensureTestImage(t, m, pc.Name)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
box, err := m.Create(ctx, sandbox.CreateOptions{
|
box, err := m.Create(ctx, sandbox.CreateOptions{
|
||||||
Image: testImage(),
|
Image: testImage(),
|
||||||
|
|
@ -156,6 +157,7 @@ func TestPoolLimits_MaxTotal(t *testing.T) {
|
||||||
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
|
m := setupManagerForPool(t, pc, func(p *sandbox.Pool) {
|
||||||
p.MaxTotal = 1
|
p.MaxTotal = 1
|
||||||
})
|
})
|
||||||
|
ensureTestImage(t, m, pc.Name)
|
||||||
|
|
||||||
box1 := createTestBox(t, m)
|
box1 := createTestBox(t, m)
|
||||||
_ = box1
|
_ = box1
|
||||||
|
|
@ -236,10 +238,14 @@ func TestMultiPool(t *testing.T) {
|
||||||
|
|
||||||
var sps []sandbox.Pool
|
var sps []sandbox.Pool
|
||||||
for _, pc := range pools {
|
for _, pc := range pools {
|
||||||
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr})
|
sps = append(sps, sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options})
|
||||||
}
|
}
|
||||||
m := setupManager(t, sps...)
|
m := setupManager(t, sps...)
|
||||||
|
|
||||||
|
for _, pc := range pools {
|
||||||
|
ensureTestImage(t, m, pc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,28 @@ package sandbox_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sandbox "github.com/yaoapp/yao/sandbox/v2"
|
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 {
|
type poolConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Addr string
|
Addr string
|
||||||
|
Options []tai.Option
|
||||||
}
|
}
|
||||||
|
|
||||||
// testPools returns all available pool configurations for dual-mode testing.
|
// testPools returns all available pool configurations for multi-mode testing.
|
||||||
// Always includes "local"; includes "remote" when SANDBOX_TEST_REMOTE_ADDR is set.
|
// - local: always present (direct Docker daemon)
|
||||||
|
// - remote: when SANDBOX_TEST_REMOTE_ADDR is set (Tai proxy → Docker)
|
||||||
|
// - k8s: when TAI_TEST_K8S_HOST + TAI_TEST_KUBECONFIG are set (Tai proxy → K8s)
|
||||||
func testPools() []poolConfig {
|
func testPools() []poolConfig {
|
||||||
pools := []poolConfig{
|
pools := []poolConfig{
|
||||||
{Name: "local", Addr: testLocalAddr()},
|
{Name: "local", Addr: testLocalAddr()},
|
||||||
|
|
@ -23,6 +31,25 @@ func testPools() []poolConfig {
|
||||||
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
|
if addr := os.Getenv("SANDBOX_TEST_REMOTE_ADDR"); addr != "" {
|
||||||
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
|
pools = append(pools, poolConfig{Name: "remote", Addr: addr})
|
||||||
}
|
}
|
||||||
|
if host := os.Getenv("TAI_TEST_K8S_HOST"); host != "" {
|
||||||
|
kubeconfig := os.Getenv("TAI_TEST_KUBECONFIG")
|
||||||
|
if kubeconfig == "" {
|
||||||
|
return pools
|
||||||
|
}
|
||||||
|
addr := fmt.Sprintf("tai://%s", host)
|
||||||
|
opts := []tai.Option{
|
||||||
|
tai.K8s,
|
||||||
|
tai.WithKubeConfig(kubeconfig),
|
||||||
|
tai.WithPorts(tai.Ports{
|
||||||
|
K8s: envPort("TAI_TEST_K8S_PORT", 6443),
|
||||||
|
GRPC: envPort("TAI_TEST_GRPC_PORT", 9100),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" {
|
||||||
|
opts = append(opts, tai.WithNamespace(ns))
|
||||||
|
}
|
||||||
|
pools = append(pools, poolConfig{Name: "k8s", Addr: addr, Options: opts})
|
||||||
|
}
|
||||||
return pools
|
return pools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,6 +82,15 @@ func testImage() string {
|
||||||
return "alpine:latest"
|
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 {
|
func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := sandbox.Config{Pool: pools}
|
cfg := sandbox.Config{Pool: pools}
|
||||||
|
|
@ -70,13 +106,49 @@ func setupManager(t *testing.T, pools ...sandbox.Pool) *sandbox.Manager {
|
||||||
|
|
||||||
func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager {
|
func setupManagerForPool(t *testing.T, pc poolConfig, mutators ...func(*sandbox.Pool)) *sandbox.Manager {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr}
|
pool := sandbox.Pool{Name: pc.Name, Addr: pc.Addr, Options: pc.Options}
|
||||||
for _, fn := range mutators {
|
for _, fn := range mutators {
|
||||||
fn(&pool)
|
fn(&pool)
|
||||||
}
|
}
|
||||||
return setupManager(t, 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 {
|
func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.CreateOptions)) *sandbox.Box {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
co := sandbox.CreateOptions{
|
co := sandbox.CreateOptions{
|
||||||
|
|
@ -86,8 +158,24 @@ func createTestBox(t *testing.T, m *sandbox.Manager, opts ...func(*sandbox.Creat
|
||||||
for _, fn := range opts {
|
for _, fn := range opts {
|
||||||
fn(&co)
|
fn(&co)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
|
pool := co.Pool
|
||||||
|
if pool == "" {
|
||||||
|
pools := m.Pools()
|
||||||
|
if len(pools) > 0 {
|
||||||
|
pool = pools[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
defer cancel()
|
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)
|
box, err := m.Create(ctx, co)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Create: %v", err)
|
t.Fatalf("Create: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ const (
|
||||||
Persistent LifecyclePolicy = "persistent"
|
Persistent LifecyclePolicy = "persistent"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const DefaultStopTimeout = 2 * time.Second
|
||||||
|
|
||||||
type Pool struct {
|
type Pool struct {
|
||||||
Name string
|
Name string
|
||||||
Addr string
|
Addr string
|
||||||
|
|
@ -24,6 +26,7 @@ type Pool struct {
|
||||||
MaxTotal int
|
MaxTotal int
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
MaxLifetime time.Duration
|
MaxLifetime time.Duration
|
||||||
|
StopTimeout time.Duration // SIGTERM grace period before SIGKILL; 0 = DefaultStopTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
type PoolInfo struct {
|
type PoolInfo struct {
|
||||||
|
|
@ -59,6 +62,12 @@ type CreateOptions struct {
|
||||||
Ports []PortMapping
|
Ports []PortMapping
|
||||||
Policy LifecyclePolicy
|
Policy LifecyclePolicy
|
||||||
IdleTimeout time.Duration
|
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 {
|
type ListOptions struct {
|
||||||
|
|
@ -133,6 +142,18 @@ func WithHeaders(headers map[string]string) AttachOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImagePullOptions configures an image pull operation.
|
||||||
|
type ImagePullOptions struct {
|
||||||
|
Auth *RegistryAuth // nil = anonymous / public
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryAuth holds credentials for a private container registry.
|
||||||
|
type RegistryAuth struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Server string
|
||||||
|
}
|
||||||
|
|
||||||
type ServiceConn struct {
|
type ServiceConn struct {
|
||||||
Read func() ([]byte, error)
|
Read func() ([]byte, error)
|
||||||
Write func(data []byte) error
|
Write func(data []byte) error
|
||||||
|
|
|
||||||
20
tai/sandbox/client_accessor.go
Normal file
20
tai/sandbox/client_accessor.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
43
tai/sandbox/image.go
Normal file
43
tai/sandbox/image.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
132
tai/sandbox/image_docker.go
Normal file
132
tai/sandbox/image_docker.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
25
tai/sandbox/image_k8s.go
Normal file
25
tai/sandbox/image_k8s.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -113,7 +113,7 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
|
||||||
container := corev1.Container{
|
container := corev1.Container{
|
||||||
Name: "main",
|
Name: "main",
|
||||||
Image: opts.Image,
|
Image: opts.Image,
|
||||||
Command: opts.Cmd,
|
Args: opts.Cmd,
|
||||||
Env: envVars,
|
Env: envVars,
|
||||||
WorkingDir: opts.WorkingDir,
|
WorkingDir: opts.WorkingDir,
|
||||||
}
|
}
|
||||||
|
|
@ -160,9 +160,16 @@ func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, er
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *k8sSandbox) Start(ctx context.Context, id string) error {
|
func (s *k8sSandbox) Start(ctx context.Context, id string) error {
|
||||||
// K8s pods start automatically after creation.
|
if _, ok := ctx.Deadline(); !ok {
|
||||||
// Wait briefly for the pod to leave Pending.
|
var cancel context.CancelFunc
|
||||||
for i := 0; i < 30; i++ {
|
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{})
|
pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get pod: %w", err)
|
return fmt.Errorf("get pod: %w", err)
|
||||||
|
|
@ -170,9 +177,13 @@ func (s *k8sSandbox) Start(ctx context.Context, id string) error {
|
||||||
if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
|
if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
|
||||||
return nil
|
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 {
|
func (s *k8sSandbox) Stop(ctx context.Context, id string, timeout time.Duration) error {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package sandbox
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/client"
|
"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) {
|
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 {
|
func (l *local) Start(ctx context.Context, id string) error {
|
||||||
|
|
@ -71,12 +70,6 @@ func (l *local) Close() error {
|
||||||
return l.core.cli.Close()
|
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 {
|
func portStr(p int) string {
|
||||||
if p == 0 {
|
if p == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
29
tai/tai.go
29
tai/tai.go
|
|
@ -77,6 +77,12 @@ func WithNamespace(ns string) Option {
|
||||||
return optionFunc(func(c *config) { c.namespace = ns })
|
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 {
|
type config struct {
|
||||||
runtime Runtime
|
runtime Runtime
|
||||||
ports Ports
|
ports Ports
|
||||||
|
|
@ -85,6 +91,7 @@ type config struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
kubeConfig string
|
kubeConfig string
|
||||||
namespace string
|
namespace string
|
||||||
|
volume volume.Volume // override volume (for testing without Docker)
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultPorts() Ports {
|
func defaultPorts() Ports {
|
||||||
|
|
@ -121,8 +128,10 @@ type Client struct {
|
||||||
host string
|
host string
|
||||||
addr string
|
addr string
|
||||||
ports Ports
|
ports Ports
|
||||||
|
dataDir string // host-side data directory for local volume
|
||||||
vol volume.Volume
|
vol volume.Volume
|
||||||
sb sandbox.Sandbox
|
sb sandbox.Sandbox
|
||||||
|
img sandbox.Image
|
||||||
prx proxy.Proxy
|
prx proxy.Proxy
|
||||||
vc vnc.VNC
|
vc vnc.VNC
|
||||||
grpcConn *grpc.ClientConn
|
grpcConn *grpc.ClientConn
|
||||||
|
|
@ -170,18 +179,27 @@ func New(addr string, opts ...Option) (*Client, error) {
|
||||||
|
|
||||||
func (c *Client) initLocal(cfg *config) (*Client, error) {
|
func (c *Client) initLocal(cfg *config) (*Client, error) {
|
||||||
sb, err := sandbox.NewLocal(c.addr)
|
sb, err := sandbox.NewLocal(c.addr)
|
||||||
if err != nil {
|
if err != nil && cfg.volume == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if sb != nil {
|
||||||
c.sb = sb
|
c.sb = sb
|
||||||
|
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
|
||||||
c.prx = proxy.NewLocal(sb)
|
c.prx = proxy.NewLocal(sb)
|
||||||
c.vc = vnc.NewLocal(sb)
|
c.vc = vnc.NewLocal(sb)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.volume != nil {
|
||||||
|
c.vol = cfg.volume
|
||||||
|
c.dataDir = cfg.dataDir
|
||||||
|
} else {
|
||||||
dataDir := cfg.dataDir
|
dataDir := cfg.dataDir
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
dataDir = "/tmp/tai-volumes"
|
dataDir = "/tmp/tai-volumes"
|
||||||
}
|
}
|
||||||
|
c.dataDir = dataDir
|
||||||
c.vol = volume.NewLocal(dataDir)
|
c.vol = volume.NewLocal(dataDir)
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,6 +237,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.sb = sb
|
c.sb = sb
|
||||||
|
c.img = sandbox.NewK8sImage()
|
||||||
default:
|
default:
|
||||||
dockerPort := c.ports.Docker
|
dockerPort := c.ports.Docker
|
||||||
if dockerPort == 0 {
|
if dockerPort == 0 {
|
||||||
|
|
@ -231,6 +250,7 @@ func (c *Client) initRemote(cfg *config) (*Client, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.sb = sb
|
c.sb = sb
|
||||||
|
c.img = sandbox.NewDockerImage(sandbox.DockerCli(sb))
|
||||||
}
|
}
|
||||||
|
|
||||||
hc := cfg.httpClient
|
hc := cfg.httpClient
|
||||||
|
|
@ -266,6 +286,10 @@ func (c *Client) Close() error {
|
||||||
// Volume returns the Volume IO layer. Never nil.
|
// Volume returns the Volume IO layer. Never nil.
|
||||||
func (c *Client) Volume() volume.Volume { return c.vol }
|
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.
|
// Workspace returns an fs.FS-compatible filesystem for the given session.
|
||||||
func (c *Client) Workspace(sessionID string) workspace.FS {
|
func (c *Client) Workspace(sessionID string) workspace.FS {
|
||||||
return workspace.New(c.vol, sessionID)
|
return workspace.New(c.vol, sessionID)
|
||||||
|
|
@ -274,6 +298,9 @@ func (c *Client) Workspace(sessionID string) workspace.FS {
|
||||||
// Sandbox returns the container lifecycle manager. Never nil.
|
// Sandbox returns the container lifecycle manager. Never nil.
|
||||||
func (c *Client) Sandbox() sandbox.Sandbox { return c.sb }
|
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.
|
// Proxy returns the HTTP reverse proxy helper. Never nil.
|
||||||
func (c *Client) Proxy() proxy.Proxy { return c.prx }
|
func (c *Client) Proxy() proxy.Proxy { return c.prx }
|
||||||
|
|
||||||
|
|
|
||||||
600
workspace/DESIGN.md
Normal file
600
workspace/DESIGN.md
Normal file
|
|
@ -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
|
||||||
36
workspace/Makefile
Normal file
36
workspace/Makefile
Normal file
|
|
@ -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
|
||||||
74
workspace/TEST.md
Normal file
74
workspace/TEST.md
Normal file
|
|
@ -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/
|
||||||
|
```
|
||||||
199
workspace/bench_test.go
Normal file
199
workspace/bench_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
10
workspace/errors.go
Normal file
10
workspace/errors.go
Normal file
|
|
@ -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")
|
||||||
|
)
|
||||||
232
workspace/fileio_test.go
Normal file
232
workspace/fileio_test.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
319
workspace/manager.go
Normal file
319
workspace/manager.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
89
workspace/testutils_test.go
Normal file
89
workspace/testutils_test.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
78
workspace/workspace.go
Normal file
78
workspace/workspace.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
323
workspace/workspace_test.go
Normal file
323
workspace/workspace_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue