commit e0e362d70034af8d1571ae8253212c7606574fd1 Author: wehub-resource-sync Date: Mon Jul 13 13:39:33 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c871917 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,26 @@ +# CODEOWNERS for OpenSandbox +# Rules are evaluated top-to-bottom; the last matching pattern wins. + +# Default owners (fallback for files not matched by specific rules) +* @jwx0925 @hittyt @Pangjiping @ninan-nn + +# Control plane (server) +/server/ @Pangjiping @hittyt @jwx0925 @Generalwin @ninan-nn + +# Runtime agent (execd) and sandbox images +/components/execd/ @Pangjiping @hittyt @ninan-nn +/components/ingress/ @Pangjiping @hittyt @Generalwin @Spground +/components/egress/ @Pangjiping @hittyt @jwx0925 +/sandboxes/ @Pangjiping @ninan-nn @jwx0925 @hittyt + +# Kubernetes controller +/kubernetes/ @Spground @Generalwin @fengcone @kevinlynx @ninan-nn @hittyt @Pangjiping + +# SDKs +/sdks/ @ninan-nn @jwx0925 @hittyt @Pangjiping + +# Specs and docs +/specs/ @jwx0925 @hittyt @ninan-nn + +# OpenSandbox Enhancement Proposals +/oseps/ @Spground @Generalwin @fengcone @kevinlynx @Pangjiping @ninan-nn @jwx0925 @hittyt diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md new file mode 100644 index 0000000..bf934b3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -0,0 +1,19 @@ +--- +name: Feature Request +about: Suggest an idea for OpenSandbox +title: '' +labels: '' +assignees: '' + +--- + +## Why do you need it? +Is your feature request related to a problem? Please describe in details + + +## How could it be? +A clear and concise description of what you want to happen. You can explain more about input of the feature, and output of it. + + +## Other related information +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8380b3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Any Questions or Suggestions? + url: https://github.com/opensandbox-group/OpenSandbox/issues + about: Please ask and answer questions here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9564ae2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +# Summary +- What is changing and why? + +# Testing +- [ ] Not run (explain why) +- [ ] Unit tests +- [ ] Integration tests +- [ ] e2e / manual verification + +# Breaking Changes +- [ ] None +- [ ] Yes (describe impact and migration path) + +# Checklist +- [ ] Linked Issue or clearly described motivation +- [ ] Added/updated docs (if needed) +- [ ] Added/updated tests (if needed) +- [ ] Security impact considered +- [ ] Backward compatibility considered diff --git a/.github/workflows/deploy-docs-pages.yml b/.github/workflows/deploy-docs-pages.yml new file mode 100644 index 0000000..5c131e0 --- /dev/null +++ b/.github/workflows/deploy-docs-pages.yml @@ -0,0 +1,68 @@ +name: Deploy Docs Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + environment: + name: github-pages + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Setup Node 22 + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + + - name: Enable corepack + run: corepack enable + + - name: Install docs dependencies + working-directory: docs + run: pnpm install --frozen-lockfile + + - name: Build docs + working-directory: docs + env: + DOCS_BASE: ${{ hashFiles('docs/public/CNAME') != '' && '/' || steps.pages.outputs.base_path }} + run: pnpm docs:build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: docs/.vitepress/dist + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/detect-changes.yml b/.github/workflows/detect-changes.yml new file mode 100644 index 0000000..2f6f903 --- /dev/null +++ b/.github/workflows/detect-changes.yml @@ -0,0 +1,100 @@ +name: Detect Relevant Changes + +on: + workflow_call: + inputs: + area: + description: Test area whose existing path scope should be evaluated + required: true + type: string + outputs: + relevant: + description: Whether the caller's tests are relevant to this event + value: ${{ jobs.detect.outputs.relevant }} + +permissions: + contents: read + pull-requests: read + +jobs: + detect: + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.changes.outputs.relevant }} + steps: + - name: Detect relevant changes + id: changes + uses: actions/github-script@v7 + env: + AREA: ${{ inputs.area }} + with: + script: | + if (context.eventName !== "pull_request") { + core.info(`Running ${process.env.AREA} tests for ${context.eventName}.`); + core.setOutput("relevant", "true"); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }); + const paths = files.flatMap((file) => + [file.filename, file.previous_filename].filter(Boolean), + ); + + const workflowChanged = (path, caller) => + path === ".github/workflows/detect-changes.yml" || + path === `.github/workflows/${caller}`; + + const matchers = { + server: (path) => + workflowChanged(path, "server-test.yml") || + path.startsWith("server/"), + kubernetes: (path) => + workflowChanged(path, "kubernetes-test.yml") || + (path.startsWith("kubernetes/") && + !path.startsWith("kubernetes/docs/")), + "real-e2e": (path) => + workflowChanged(path, "real-e2e.yml") || + path.startsWith("server/opensandbox_server/") || + path.startsWith("components/execd/") || + path.startsWith("components/egress/") || + path.startsWith("sdks/") || + path.startsWith("tests/"), + egress: (path) => + workflowChanged(path, "egress-test.yaml.yml") || + path.startsWith("components/egress/") || + path.startsWith("components/internal/"), + "kubernetes-mini-e2e": (path) => + workflowChanged(path, "kubernetes-nightly-build.yml") || + path === "scripts/python-k8s-e2e.sh" || + path === "scripts/python-k8s-e2e-ingress.sh" || + path === "scripts/common/kubernetes-e2e.sh" || + path.startsWith("kubernetes/charts/"), + ingress: (path) => + workflowChanged(path, "ingress-test.yaml") || + path.startsWith("components/ingress/") || + path.startsWith("components/internal/"), + execd: (path) => + workflowChanged(path, "execd-test.yml") || + path.startsWith("components/execd/") || + path.startsWith("components/internal/"), + sdk: (path) => + workflowChanged(path, "sdk-tests.yml") || + path.startsWith("cli/") || + path.startsWith("sdks/") || + path.startsWith("specs/"), + }; + + const area = process.env.AREA; + if (!Object.hasOwn(matchers, area)) { + core.setFailed(`Unknown test area: ${area}`); + return; + } + + const relevant = paths.some(matchers[area]); + core.info(`${area}: relevant=${relevant}; files=${files.length}`); + core.setOutput("relevant", String(relevant)); diff --git a/.github/workflows/egress-test.yaml.yml b/.github/workflows/egress-test.yaml.yml new file mode 100644 index 0000000..b1fb9cc --- /dev/null +++ b/.github/workflows/egress-test.yaml.yml @@ -0,0 +1,135 @@ +name: Egress Tests + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: egress + + test: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.9' + + - name: Check gofmt + working-directory: components/egress + run: | + files="$(gofmt -l . ../internal)" + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Run Build + working-directory: components/egress + run: | + go vet ./... + go build . + + - name: Run tests + working-directory: components/egress + run: | + go test ./... + + smoke: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: self-hosted + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Clean Docker runner cache + run: bash scripts/ci-docker-cleanup.sh + + - name: Run dns test + working-directory: components/egress + run: | + chmod +x tests/smoke-dns.sh + ./tests/smoke-dns.sh + + - name: Run nft test + working-directory: components/egress + run: | + chmod +x tests/smoke-nft.sh + ./tests/smoke-nft.sh + + - name: Run dynamic ip test + working-directory: components/egress + run: | + chmod +x tests/smoke-dynamic-ip.sh + ./tests/smoke-dynamic-ip.sh + + - name: Run dns upstream probe test + working-directory: components/egress + run: | + chmod +x tests/smoke-dns-upstream-probe.sh + ./tests/smoke-dns-upstream-probe.sh + + bench: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Run bench test + working-directory: components/egress + run: | + chmod +x tests/bench-dns-nft.sh + ./tests/bench-dns-nft.sh + env: + BENCH_SAMPLE_SIZE: "20" + + - name: Upload egress logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: egress-log-for-bench + path: /tmp/egress-logs/ + retention-days: 5 + + required: + name: Egress CI + if: always() + needs: [changes, test, smoke, bench] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + TEST_RESULT: ${{ needs.test.result }} + SMOKE_RESULT: ${{ needs.smoke.result }} + BENCH_RESULT: ${{ needs.bench.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$TEST_RESULT" == "success" && "$SMOKE_RESULT" == "success" && "$BENCH_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" && "$SMOKE_RESULT" == "skipped" && "$BENCH_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/execd-test.yml b/.github/workflows/execd-test.yml new file mode 100644 index 0000000..a4f6688 --- /dev/null +++ b/.github/workflows/execd-test.yml @@ -0,0 +1,190 @@ +name: Execd Tests + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: execd + + test: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.9' + + - name: Check gofmt + working-directory: components/execd + run: | + files="$(gofmt -l . ../internal)" + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Run golint + working-directory: components/execd + run: | + make golint + + - name: Build (Multi platform compile) + working-directory: components/execd + run: | + make multi-build + + - name: Run tests with coverage + working-directory: components/execd + run: | + go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/... + + - name: Calculate coverage and generate summary + working-directory: components/execd + id: coverage + run: | + # Extract total coverage percentage + TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}') + echo "total_coverage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT + + # Generate GitHub Actions job summary + echo "## 📊 execd Test Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Total Line Coverage:** $TOTAL_COVERAGE" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Coverage report generated for commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "*Coverage targets: Core packages >80%, API layer >70%*" >> $GITHUB_STEP_SUMMARY + + smoke: + needs: changes + if: needs.changes.outputs.relevant == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.9' + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install make (Windows) + if: matrix.os == 'windows-latest' + shell: powershell + run: choco install make -y + + - name: Build + working-directory: components/execd + run: | + make build + + - name: Run smoke test + working-directory: components/execd + run: | + chmod +x tests/smoke.sh + ./tests/smoke.sh + + sleep 5 + python3 tests/smoke_api.py + - name: SIGTERM forward test + if: matrix.os == 'ubuntu-latest' + working-directory: components/execd + run: | + chmod +x tests/sigterm_forward.sh + ./tests/sigterm_forward.sh + + - name: Smoke test bwrap (Docker image build + extraction) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: | + chmod +x components/execd/tests/smoke_bwrap.sh + bash components/execd/tests/smoke_bwrap.sh + + - name: Show logs + if: always() + run: | + set -x + cat components/execd/startup.log || true + cat components/execd/execd.log || true + + bwrap-smoke: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.9' + + - name: Build latest bubblewrap (v0.11.2) + run: | + sudo apt-get install -y meson ninja-build libcap-dev pkg-config + git clone --depth 1 --branch v0.11.2 https://github.com/containers/bubblewrap /tmp/bwrap + cd /tmp/bwrap + meson setup builddir -Dprefix=/usr -Dman=disabled -Dtests=false + ninja -C builddir + sudo cp builddir/bwrap /usr/local/bin/bwrap + bwrap --version + + - name: Run bwrap integration tests + working-directory: components/execd + run: sudo -E env "PATH=$PATH" go test -tags="linux,bwrap" -v -count=1 -timeout=5m ./pkg/runtime/bwrap_test/ + + required: + name: Execd CI + if: always() + needs: [changes, test, smoke, bwrap-smoke] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + TEST_RESULT: ${{ needs.test.result }} + SMOKE_RESULT: ${{ needs.smoke.result }} + BWRAP_SMOKE_RESULT: ${{ needs.bwrap-smoke.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$TEST_RESULT" == "success" && "$SMOKE_RESULT" == "success" && "$BWRAP_SMOKE_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" && "$SMOKE_RESULT" == "skipped" && "$BWRAP_SMOKE_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/ingress-test.yaml b/.github/workflows/ingress-test.yaml new file mode 100644 index 0000000..b3f5b68 --- /dev/null +++ b/.github/workflows/ingress-test.yaml @@ -0,0 +1,80 @@ +name: Ingress Tests + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: ingress + + test: + needs: changes + if: needs.changes.outputs.relevant == 'true' + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25.9' + + - name: Check gofmt + working-directory: components/ingress + run: | + files="$(gofmt -l . ../internal)" + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Run golint + working-directory: components/ingress + run: | + make golint + + - name: Run Build + working-directory: components/ingress + run: | + make build + + - name: Run tests + working-directory: components/ingress + run: | + make test + + required: + name: Ingress CI + if: always() + needs: [changes, test] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + TEST_RESULT: ${{ needs.test.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$TEST_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/kubernetes-nightly-build.yml b/.github/workflows/kubernetes-nightly-build.yml new file mode 100644 index 0000000..6f72503 --- /dev/null +++ b/.github/workflows/kubernetes-nightly-build.yml @@ -0,0 +1,199 @@ +name: Kubernetes nightly build (Monorepo) + +permissions: + contents: read + pull-requests: read + packages: write + +on: + workflow_dispatch: + schedule: + - cron: "0 20 * * *" + pull_request: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: kubernetes-mini-e2e + + k8s-mini-e2e: + name: Kubernetes mini E2E (${{ matrix.variant }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: direct + script: scripts/python-k8s-e2e.sh + e2e_gateway_route_mode: "" + - variant: ingress-header + script: scripts/python-k8s-e2e-ingress.sh + e2e_gateway_route_mode: "" + - variant: ingress-uri + script: scripts/python-k8s-e2e-ingress.sh + e2e_gateway_route_mode: uri + env: + KIND_CLUSTER: opensandbox-e2e + KIND_K8S_VERSION: v1.30.4 + KUBECONFIG_PATH: /tmp/opensandbox-kind-kubeconfig + KUBECONFIG: /tmp/opensandbox-kind-kubeconfig + OPENSANDBOX_E2E_SANDBOX_CPU: 250m + OPENSANDBOX_E2E_SANDBOX_MEMORY: 512Mi + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.24.0" + + - name: Add Go bin to PATH + run: echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Set up kubectl + uses: azure/setup-kubectl@v4 + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Run Kubernetes runtime E2E + env: + E2E_GATEWAY_ROUTE_MODE: ${{ matrix.e2e_gateway_route_mode }} + run: bash "./${{ matrix.script }}" + + - name: Dump kind diagnostics + if: always() + run: | + kubectl get pods -A -o wide || true + kubectl get batchsandboxes -A || true + kubectl get pv,pvc -A || true + kubectl describe deployment -n opensandbox-system opensandbox-controller-manager || true + kubectl describe deployment -n opensandbox-system opensandbox-server || true + kubectl get svc -n opensandbox-system opensandbox-server || true + + - name: Eval in-cluster server logs + if: always() + run: | + kubectl logs -n opensandbox-system deployment/opensandbox-server || true + cat /tmp/opensandbox-server-port-forward.log || true + + - name: Upload Python test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-k8s-e2e-logs-${{ matrix.variant }} + path: | + /tmp/opensandbox-server-port-forward.log + /tmp/opensandbox-ingress-gateway-port-forward.log + if-no-files-found: ignore + retention-days: 5 + + - name: Clean up Kind cluster + if: always() + run: | + kind delete cluster --name "${KIND_CLUSTER}" || true + + publish-nightly-latest: + name: Publish latest (${{ matrix.component }} nightly) + needs: k8s-mini-e2e + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - component: execd + workdir: components/execd + - component: ingress + workdir: components/ingress + - component: egress + workdir: components/egress + - component: controller + workdir: kubernetes + k8s_component: controller + - component: task-executor + workdir: kubernetes + k8s_component: task-executor + - component: server + workdir: server + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Login to ACR + uses: docker/login-action@v3 + with: + registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com + username: ${{ secrets.ACR_USERNAME }} + password: ${{ secrets.ACR_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push (TAG=latest) + working-directory: ${{ matrix.workdir }} + run: | + export TAG=latest + export GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/opensandbox" + if [ -n "${{ matrix.k8s_component }}" ]; then + export COMPONENT="${{ matrix.k8s_component }}" + fi + chmod +x build.sh + ./build.sh + + required: + name: Kubernetes Mini E2E CI + if: ${{ always() && github.event_name == 'pull_request' }} + needs: [changes, k8s-mini-e2e] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + E2E_RESULT: ${{ needs.k8s-mini-e2e.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$E2E_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$E2E_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/kubernetes-test.yml b/.github/workflows/kubernetes-test.yml new file mode 100644 index 0000000..e38e6ea --- /dev/null +++ b/.github/workflows/kubernetes-test.yml @@ -0,0 +1,130 @@ +name: Sandbox Kubernetes Tests + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + GO_VERSION: '1.24' + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: kubernetes + + controller-e2e-core: + name: Controller E2E Core (Kubernetes v${{ matrix.version }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + strategy: + fail-fast: false + matrix: + version: ["1.21.1", "1.22.4", "1.24.4", "1.26.4", "1.28.6", "1.30.4", "1.32.2", "1.34.2"] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run core e2e tests + working-directory: kubernetes + run: | + make test-e2e-core KIND_K8S_VERSION=v${{ matrix.version }} + + controller-e2e-pause-resume: + name: Controller E2E PauseResume (Kubernetes v${{ matrix.version }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + strategy: + fail-fast: false + matrix: + version: ["1.21.1", "1.22.4", "1.24.4", "1.26.4", "1.28.6", "1.30.4", "1.32.2", "1.34.2"] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run pause-resume e2e tests + working-directory: kubernetes + run: | + make test-e2e-pause-resume KIND_K8S_VERSION=v${{ matrix.version }} + + test: + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.24.0' + + - name: Check gofmt + working-directory: kubernetes + run: | + files="$(gofmt -l .)" + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Run golint + working-directory: kubernetes + run: | + make lint + + - name: Build binary + working-directory: kubernetes + run: | + make build + make task-executor-build + + - name: Run tests + working-directory: kubernetes + run: | + make test + + required: + name: Kubernetes CI + if: always() + needs: [changes, controller-e2e-core, controller-e2e-pause-resume, test] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + CORE_RESULT: ${{ needs.controller-e2e-core.result }} + PAUSE_RESUME_RESULT: ${{ needs.controller-e2e-pause-resume.result }} + TEST_RESULT: ${{ needs.test.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$CORE_RESULT" == "success" && "$PAUSE_RESUME_RESULT" == "success" && "$TEST_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$CORE_RESULT" == "skipped" && "$PAUSE_RESUME_RESULT" == "skipped" && "$TEST_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml new file mode 100644 index 0000000..ad8105c --- /dev/null +++ b/.github/workflows/pr-label-check.yml @@ -0,0 +1,106 @@ +name: PR Auto Label + +on: + pull_request_target: + types: [opened, reopened, synchronize] + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + auto-label: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const prNumber = pr.number; + const owner = context.repo.owner; + const repo = context.repo.repo; + + function inferLabels(files) { + const dirs = new Set(); + for (const f of files) { + const p = f.filename.split("/"); + for (let i = 1; i <= p.length; i++) { + dirs.add(p.slice(0, i).join("/")); + } + } + const s = []; + + if (dirs.has("docs") || dirs.has("oseps") || dirs.has("specs") || + dirs.has("examples")) { + s.push("documentation"); + } + + if (dirs.has("components/egress")) s.push("component/egress"); + if (dirs.has("components/ingress")) s.push("component/ingress"); + if (dirs.has("components/execd")) s.push("component/execd"); + if (dirs.has("components/code-interpreter")) s.push("component/code-interpreter"); + if (dirs.has("components/internal")) { + s.push("component/egress", "component/ingress"); + } + + if (dirs.has("kubernetes")) s.push("component/k8s"); + if (dirs.has("server")) s.push("component/server"); + + // SDK — layout: sdks/{sandbox,code-interpreter,mcp}/{go,python,...} + if (dirs.has("sdks")) { + s.push("sdks"); + const langMap = { + go: "sdk/go", + kotlin: "sdk/java", + python: "sdk/python", + javascript: "sdk/js", + csharp: "sdk/c#", + }; + for (const [dir, label] of Object.entries(langMap)) { + for (const d of dirs) { + if (d.startsWith("sdks/") && d.endsWith("/" + dir)) { + s.push(label); + break; + } + } + } + } + + return [...new Set(s)].sort(); + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNumber, + }); + const inferred = inferLabels(files); + + if (inferred.length === 0) { + core.info("No labels inferred from changed files, skipping."); + return; + } + + const existing = new Set(pr.labels.map(l => l.name)); + const toAdd = inferred.filter(l => !existing.has(l)); + + if (toAdd.length === 0) { + core.info("All inferred labels already present."); + return; + } + + const { data: repoLabels } = await github.rest.issues.listLabelsForRepo({ + owner, repo, + }); + const repoLabelNames = new Set(repoLabels.map(l => l.name)); + const valid = toAdd.filter(l => repoLabelNames.has(l)); + + if (valid.length === 0) { + core.info("Inferred labels don't exist in repo, skipping."); + return; + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, + labels: valid, + }); + core.info(`Added labels: ${valid.join(", ")}`); diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 0000000..21e9efd --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,72 @@ +name: Publish CLI + +on: + push: + tags: + - 'cli/v*' + +permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish-pypi: + needs: release-preflight + if: startsWith(github.ref, 'refs/tags/cli/v') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Install dependencies + working-directory: cli + run: | + uv sync --frozen + + - name: Run lint + working-directory: cli + run: | + uv run ruff check + + - name: Run type checks + working-directory: cli + run: | + uv run pyright + + - name: Run tests + working-directory: cli + run: | + uv run pytest + + - name: Build package + working-directory: cli + run: | + uv build + + - name: Attest built package + if: startsWith(github.ref, 'refs/tags/cli/v') + uses: actions/attest@v4 + with: + subject-path: cli/dist/* + + - name: Publish to PyPI + working-directory: cli + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + uv publish diff --git a/.github/workflows/publish-components.yml b/.github/workflows/publish-components.yml new file mode 100644 index 0000000..a848596 --- /dev/null +++ b/.github/workflows/publish-components.yml @@ -0,0 +1,224 @@ +name: Publish Components Image + +permissions: + # required for bump step to push branch and create PR + contents: write + pull-requests: write + id-token: write + attestations: write + artifact-metadata: write + packages: write + +on: + workflow_dispatch: + inputs: + component: + description: 'Component to build' + required: true + type: choice + options: + - execd + - code-interpreter + - ingress + - egress + - controller + - task-executor + - image-committer + default: 'execd' + image_tag: + description: 'Docker image tag' + required: true + default: 'latest' + push: + tags: + - 'docker/execd/**' + - 'docker/code-interpreter/**' + - 'docker/ingress/**' + - 'docker/egress/**' + - 'k8s/controller/**' + - 'k8s/task-executor/**' + - 'k8s/image-committer/**' + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish: + needs: release-preflight + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install Cosign + if: github.ref_type == 'tag' + uses: sigstore/cosign-installer@v4.1.1 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Login to ACR + uses: docker/login-action@v3 + with: + registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com + username: ${{ secrets.ACR_USERNAME }} + password: ${{ secrets.ACR_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Parse tag and set variables + id: parse_tag + run: | + if [[ "${{ github.ref }}" == refs/tags/docker/* ]]; then + TAG_PATH="${{ github.ref }}" + TAG_PATH="${TAG_PATH#refs/tags/}" + + COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2) + IMAGE_TAG=$(echo "$TAG_PATH" | cut -d'/' -f3) + + echo "component=$COMPONENT" >> $GITHUB_OUTPUT + echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == refs/tags/k8s/* ]]; then + TAG_PATH="${{ github.ref }}" + TAG_PATH="${TAG_PATH#refs/tags/}" + + COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2) + IMAGE_TAG=$(echo "$TAG_PATH" | cut -d'/' -f3) + + echo "component=$COMPONENT" >> $GITHUB_OUTPUT + echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + else + echo "component=${{ inputs.component }}" >> $GITHUB_OUTPUT + echo "image_tag=${{ inputs.image_tag }}" >> $GITHUB_OUTPUT + fi + + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache + sudo apt-get clean + sudo rm -rf /var/lib/apt/lists/* + df -h + + - name: Build and push to registries + id: build + env: + BUILD_METADATA_FILE: ${{ runner.temp }}/opensandbox-component-image-metadata.json + run: | + COMPONENT="${{ steps.parse_tag.outputs.component }}" + IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}" + + if [ "$COMPONENT" == "execd" ]; then + cd components/execd + elif [ "$COMPONENT" == "ingress" ]; then + cd components/ingress + elif [ "$COMPONENT" == "egress" ]; then + cd components/egress + elif [ "$COMPONENT" == "controller" ]; then + cd kubernetes + elif [ "$COMPONENT" == "task-executor" ]; then + cd kubernetes + elif [ "$COMPONENT" == "image-committer" ]; then + cd kubernetes + else + cd sandboxes/$COMPONENT + fi + + export TAG=$IMAGE_TAG + export COMPONENT=$COMPONENT + export GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/opensandbox" + chmod +x build.sh + ./build.sh + + DIGEST="$(jq -r '."containerimage.digest" // empty' "$BUILD_METADATA_FILE")" + if [[ -z "$DIGEST" ]]; then + echo "Unable to resolve image digest from $BUILD_METADATA_FILE" >&2 + cat "$BUILD_METADATA_FILE" >&2 + exit 1 + fi + + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "dockerhub_image=docker.io/opensandbox/$COMPONENT" >> "$GITHUB_OUTPUT" + echo "acr_image=sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/$COMPONENT" >> "$GITHUB_OUTPUT" + echo "ghcr_image=$GHCR_REPO/$COMPONENT" >> "$GITHUB_OUTPUT" + + - name: Sign release images + if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + env: + DIGEST: ${{ steps.build.outputs.digest }} + DOCKERHUB_IMAGE: ${{ steps.build.outputs.dockerhub_image }} + ACR_IMAGE: ${{ steps.build.outputs.acr_image }} + GHCR_IMAGE: ${{ steps.build.outputs.ghcr_image }} + run: | + set -euo pipefail + cosign sign --yes "${DOCKERHUB_IMAGE}@${DIGEST}" "${ACR_IMAGE}@${DIGEST}" "${GHCR_IMAGE}@${DIGEST}" + + - name: Attest Docker Hub image + if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + uses: actions/attest@v4 + with: + subject-name: ${{ steps.build.outputs.dockerhub_image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest ACR image + if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + uses: actions/attest@v4 + with: + subject-name: ${{ steps.build.outputs.acr_image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest GHCR image + if: github.ref_type == 'tag' && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + uses: actions/attest@v4 + with: + subject-name: ${{ steps.build.outputs.ghcr_image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Bump component version in repo + if: steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + COMPONENT="${{ steps.parse_tag.outputs.component }}" + IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}" + # Ensure version has 'v' prefix for bump script + if [[ "$IMAGE_TAG" =~ ^v ]]; then + VERSION="$IMAGE_TAG" + else + VERSION="v${IMAGE_TAG}" + fi + + ./scripts/bump-component-version.sh "$COMPONENT" "$VERSION" + + BRANCH="bump/${COMPONENT}-${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add -A + git diff --staged --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore: bump $COMPONENT to $VERSION" + git push origin "$BRANCH" + + gh pr create \ + --title "chore: bump $COMPONENT to $VERSION" \ + --body "Auto-generated by Publish Components workflow after building \`$COMPONENT:$VERSION\`." \ + --base "$(gh api repos/${{ github.repository }} --jq .default_branch)" diff --git a/.github/workflows/publish-csharp-sdks.yml b/.github/workflows/publish-csharp-sdks.yml new file mode 100644 index 0000000..3a1dbb7 --- /dev/null +++ b/.github/workflows/publish-csharp-sdks.yml @@ -0,0 +1,88 @@ +name: Publish C# SDKs + +on: + push: + tags: + - "csharp/sandbox/v*" + - "csharp/code-interpreter/v*" + +permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish: + needs: release-preflight + name: Publish (${{ matrix.sdk.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: + - name: sandbox + tagPrefix: sandbox + csprojPath: sdks/sandbox/csharp/src/OpenSandbox/OpenSandbox.csproj + - name: code-interpreter + tagPrefix: code-interpreter + csprojPath: sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/OpenSandbox.CodeInterpreter.csproj + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Parse package version from tag + if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) + shell: bash + run: | + VERSION="${GITHUB_REF_NAME#csharp/${{ matrix.sdk.tagPrefix }}/v}" + echo "PACKAGE_VERSION=$VERSION" >> "$GITHUB_ENV" + + - name: Restore + if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) + run: | + EXTRA_RESTORE_ARGS="" + if [ "${{ matrix.sdk.name }}" = "code-interpreter" ]; then + EXTRA_RESTORE_ARGS="-p:UseLocalOpenSandboxProjectReference=false" + fi + dotnet restore "${{ matrix.sdk.csprojPath }}" ${EXTRA_RESTORE_ARGS} + + - name: Pack + if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) + run: | + EXTRA_PACK_ARGS="" + if [ "${{ matrix.sdk.name }}" = "code-interpreter" ]; then + EXTRA_PACK_ARGS="-p:UseLocalOpenSandboxProjectReference=false" + fi + dotnet pack "${{ matrix.sdk.csprojPath }}" \ + --configuration Release \ + --no-restore \ + -p:PackageVersion="${PACKAGE_VERSION}" \ + -p:ContinuousIntegrationBuild=true \ + ${EXTRA_PACK_ARGS} \ + --output ./artifacts/${{ matrix.sdk.name }} + + - name: Attest built package + if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) + uses: actions/attest@v4 + with: + subject-path: ./artifacts/${{ matrix.sdk.name }}/*.nupkg + + - name: Publish to NuGet + if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + dotnet nuget push "./artifacts/${{ matrix.sdk.name }}/*.nupkg" \ + --api-key "$NUGET_API_KEY" \ + --source "https://api.nuget.org/v3/index.json" \ + --skip-duplicate diff --git a/.github/workflows/publish-helm-chart.yml b/.github/workflows/publish-helm-chart.yml new file mode 100644 index 0000000..68103d7 --- /dev/null +++ b/.github/workflows/publish-helm-chart.yml @@ -0,0 +1,201 @@ +name: Publish Helm Chart + +on: + workflow_dispatch: + inputs: + component: + description: 'Component to release' + required: true + type: choice + options: + - opensandbox-controller + - opensandbox-server + - opensandbox + default: 'opensandbox-controller' + app_version: + description: 'App version (without v prefix, e.g., 0.1.0)' + required: true + default: '0.1.0' + push: + tags: + - 'helm/**' # Format: helm//, e.g., helm/opensandbox-controller/0.1.0 + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish: + needs: release-preflight + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Parse tag and set variables + id: parse_tag + run: | + if [[ "${{ github.ref }}" == refs/tags/helm/* ]]; then + TAG_PATH="${{ github.ref }}" + TAG_PATH="${TAG_PATH#refs/tags/}" + + COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2) + VERSION=$(echo "$TAG_PATH" | cut -d'/' -f3) + + # Remove 'v' prefix if present + VERSION=${VERSION#v} + + echo "component=$COMPONENT" >> $GITHUB_OUTPUT + echo "app_version=$VERSION" >> $GITHUB_OUTPUT + echo "release_tag=$TAG_PATH" >> $GITHUB_OUTPUT + else + echo "component=${{ inputs.component }}" >> $GITHUB_OUTPUT + echo "app_version=${{ inputs.app_version }}" >> $GITHUB_OUTPUT + echo "release_tag=helm/${{ inputs.component }}/${{ inputs.app_version }}" >> $GITHUB_OUTPUT + fi + + - name: Verify release tag on origin + env: + RELEASE_TAG: ${{ steps.parse_tag.outputs.release_tag }} + run: | + set -euo pipefail + local_commit="$(git rev-parse 'HEAD^{commit}')" + remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}^{}" | awk 'NR == 1 { print $1 }')" + + if [[ -z "$remote_commit" ]]; then + remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}" | awk 'NR == 1 { print $1 }')" + fi + + if [[ -z "$remote_commit" ]]; then + echo "::error::Release tag '${RELEASE_TAG}' does not exist on origin. Have an authorized release manager push the protected tag before publishing the Helm chart." + exit 1 + fi + + if [[ "$local_commit" != "$remote_commit" ]]; then + echo "::error::Current commit is ${local_commit}, but origin tag '${RELEASE_TAG}' resolves to ${remote_commit}. Refusing to publish the Helm chart." + exit 1 + fi + + echo "Verified release tag '${RELEASE_TAG}' at ${remote_commit}." + + - name: Set chart path + id: chart_path + run: | + COMPONENT="${{ steps.parse_tag.outputs.component }}" + + if [ "$COMPONENT" == "opensandbox-controller" ]; then + CHART_PATH="kubernetes/charts/opensandbox-controller" + elif [ "$COMPONENT" == "opensandbox-server" ]; then + CHART_PATH="kubernetes/charts/opensandbox-server" + elif [ "$COMPONENT" == "opensandbox" ]; then + CHART_PATH="kubernetes/charts/opensandbox" + else + echo "Error: Unknown component: $COMPONENT" + exit 1 + fi + + echo "path=$CHART_PATH" >> $GITHUB_OUTPUT + + - name: Get chart version from Chart.yaml + id: chart_version + run: | + CHART_PATH="${{ steps.chart_path.outputs.path }}" + CHART_VERSION=$(grep '^version:' $CHART_PATH/Chart.yaml | awk '{print $2}') + echo "version=$CHART_VERSION" >> $GITHUB_OUTPUT + echo "Chart version: $CHART_VERSION" + + - name: Update Chart.yaml with app version + run: | + APP_VERSION="${{ steps.parse_tag.outputs.app_version }}" + CHART_PATH="${{ steps.chart_path.outputs.path }}" + + # Only update appVersion, keep chart version as-is in Chart.yaml + sed -i "s/^appVersion:.*/appVersion: \"$APP_VERSION\"/" $CHART_PATH/Chart.yaml + + echo "Updated Chart.yaml:" + cat $CHART_PATH/Chart.yaml + + - name: Build dependencies (for opensandbox all-in-one chart) + if: ${{ steps.parse_tag.outputs.component == 'opensandbox' }} + run: | + CHART_PATH="${{ steps.chart_path.outputs.path }}" + echo "Building dependencies for all-in-one chart..." + helm dependency build $CHART_PATH + + - name: Lint Helm chart + run: | + CHART_PATH="${{ steps.chart_path.outputs.path }}" + helm lint $CHART_PATH + + - name: Package Helm chart + run: | + CHART_PATH="${{ steps.chart_path.outputs.path }}" + helm package $CHART_PATH + + - name: Attest packaged Helm chart + uses: actions/attest@v4 + with: + subject-path: ${{ steps.parse_tag.outputs.component }}-*.tgz + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.parse_tag.outputs.release_tag }} + name: Helm Chart ${{ steps.parse_tag.outputs.component }} ${{ steps.chart_version.outputs.version }} (App v${{ steps.parse_tag.outputs.app_version }}) + body: | + ## ${{ steps.parse_tag.outputs.component }} Helm Chart + + **Chart Version:** ${{ steps.chart_version.outputs.version }} + **App Version:** ${{ steps.parse_tag.outputs.app_version }} + + ### Installation + + 直接从 GitHub Release 安装: + + ```bash + helm install ${{ steps.parse_tag.outputs.component }} \ + https://github.com/${{ github.repository }}/releases/download/${{ steps.parse_tag.outputs.release_tag }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \ + --namespace opensandbox-system \ + --create-namespace + ``` + + 或者先下载后安装: + + ```bash + # 下载 + wget https://github.com/${{ github.repository }}/releases/download/${{ steps.parse_tag.outputs.release_tag }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz + + # 安装 + helm install ${{ steps.parse_tag.outputs.component }} ./${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \ + --namespace opensandbox-system \ + --create-namespace + ``` + + ${{ steps.parse_tag.outputs.component == 'opensandbox' && '**Note**: This is an all-in-one chart that bundles controller and server. The packaged chart already includes all dependencies, no need to run `helm dependency build` when installing from release.' || '' }} + + ### What's Changed + + - Chart version: ${{ steps.chart_version.outputs.version }} + - App version: ${{ steps.parse_tag.outputs.app_version }} + files: | + ${{ steps.parse_tag.outputs.component }}-*.tgz + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-java-sdks.yml b/.github/workflows/publish-java-sdks.yml new file mode 100644 index 0000000..da9f329 --- /dev/null +++ b/.github/workflows/publish-java-sdks.yml @@ -0,0 +1,53 @@ +name: Publish Java SDKs + +on: + push: + tags: + - "java/sandbox/v*" + - "java/code-interpreter/v*" + +permissions: + contents: read + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish: + needs: release-preflight + name: Publish (${{ matrix.sdk.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: + - name: sandbox + tagPrefix: sandbox + workingDirectory: sdks/sandbox/kotlin + - name: code-interpreter + tagPrefix: code-interpreter + workingDirectory: sdks/code-interpreter/kotlin + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Publish to Maven Central + working-directory: ${{ matrix.sdk.workingDirectory }} + if: startsWith(github.ref, format('refs/tags/java/{0}/v', matrix.sdk.tagPrefix)) + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEYPASSWORD }} + run: | + ./gradlew publishAndReleaseToMavenCentral diff --git a/.github/workflows/publish-js-sdks.yml b/.github/workflows/publish-js-sdks.yml new file mode 100644 index 0000000..db8c471 --- /dev/null +++ b/.github/workflows/publish-js-sdks.yml @@ -0,0 +1,99 @@ +name: Publish JavaScript SDKs + +on: + push: + tags: + - "js/sandbox/v*" + - "js/code-interpreter/v*" + +permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish: + needs: release-preflight + name: Publish (${{ matrix.sdk.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: + - name: sandbox + tagPrefix: sandbox + workingDirectory: sdks/sandbox/javascript + packageName: "@alibaba-group/opensandbox" + - name: code-interpreter + tagPrefix: code-interpreter + workingDirectory: sdks/code-interpreter/javascript + packageName: "@alibaba-group/opensandbox-code-interpreter" + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + run_install: false + + - name: Get pnpm store path + id: pnpm-store + working-directory: sdks + run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v5 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-${{ hashFiles('sdks/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm- + + - name: Install workspace dependencies + working-directory: sdks + run: pnpm install --frozen-lockfile + + - name: Build SDK + working-directory: sdks + run: pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build + + - name: Pack SDK + if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix)) + id: pack + working-directory: ${{ matrix.sdk.workingDirectory }} + run: | + set -euo pipefail + PACK_DIR="${GITHUB_WORKSPACE}/dist/npm/${{ matrix.sdk.name }}" + mkdir -p "$PACK_DIR" + pnpm pack --pack-destination "$PACK_DIR" + PACKAGE_TARBALL="$(find "$PACK_DIR" -maxdepth 1 -name '*.tgz' -print -quit)" + if [[ -z "$PACKAGE_TARBALL" ]]; then + echo "No package tarball was produced in $PACK_DIR" >&2 + exit 1 + fi + echo "tarball=$PACKAGE_TARBALL" >> "$GITHUB_OUTPUT" + + - name: Attest packed SDK + if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix)) + uses: actions/attest@v4 + with: + subject-path: ${{ steps.pack.outputs.tarball }} + + - name: Publish to npm + if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix)) + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + pnpm publish "${{ steps.pack.outputs.tarball }}" --access public --no-git-checks diff --git a/.github/workflows/publish-python-sdks.yml b/.github/workflows/publish-python-sdks.yml new file mode 100644 index 0000000..9736cf5 --- /dev/null +++ b/.github/workflows/publish-python-sdks.yml @@ -0,0 +1,131 @@ +name: Publish Python SDKs + +permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + +on: + push: + tags: + - "python/sandbox/v*" + - "python/code-interpreter/v*" + - "python/mcp/sandbox/v*" + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish-sandbox: + needs: release-preflight + if: startsWith(github.ref, 'refs/tags/python/sandbox/v') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Generate API + working-directory: sdks/sandbox/python + run: | + uv run python scripts/generate_api.py + + - name: Build package + working-directory: sdks/sandbox/python + run: | + uv build + + - name: Attest built package + if: startsWith(github.ref, 'refs/tags/python/sandbox/v') + uses: actions/attest@v4 + with: + subject-path: sdks/sandbox/python/dist/* + + - name: Publish to PyPI + working-directory: sdks/sandbox/python + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + uv publish + + publish-code-interpreter: + needs: release-preflight + if: startsWith(github.ref, 'refs/tags/python/code-interpreter/v') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Build package + working-directory: sdks/code-interpreter/python + run: | + uv build + + - name: Attest built package + if: startsWith(github.ref, 'refs/tags/python/code-interpreter/v') + uses: actions/attest@v4 + with: + subject-path: sdks/code-interpreter/python/dist/* + + - name: Publish to PyPI + working-directory: sdks/code-interpreter/python + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + uv publish + + publish-mcp-sandbox: + needs: release-preflight + if: startsWith(github.ref, 'refs/tags/python/mcp/sandbox/v') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.10" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Build package + working-directory: sdks/mcp/sandbox/python + run: | + uv build + + - name: Attest built package + if: startsWith(github.ref, 'refs/tags/python/mcp/sandbox/v') + uses: actions/attest@v4 + with: + subject-path: sdks/mcp/sandbox/python/dist/* + + - name: Publish to PyPI + working-directory: sdks/mcp/sandbox/python + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + uv publish diff --git a/.github/workflows/publish-server.yml b/.github/workflows/publish-server.yml new file mode 100644 index 0000000..bfe975c --- /dev/null +++ b/.github/workflows/publish-server.yml @@ -0,0 +1,222 @@ +name: Publish Server + +on: + push: + tags: + - 'server/v*' + +permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + packages: write + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + + publish-pypi: + needs: release-preflight + if: startsWith(github.ref, 'refs/tags/server/v') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Build package + working-directory: server + run: | + uv build + + - name: Attest built package + if: startsWith(github.ref, 'refs/tags/server/v') + uses: actions/attest@v4 + with: + subject-path: server/dist/* + + - name: Publish to PyPI + working-directory: server + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + uv publish + + publish-image: + needs: release-preflight + if: startsWith(github.ref, 'refs/tags/server/v') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install Cosign + if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') + uses: sigstore/cosign-installer@v4.1.1 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Login to ACR + uses: docker/login-action@v3 + with: + registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com + username: ${{ secrets.ACR_USERNAME }} + password: ${{ secrets.ACR_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Parse tag and set variables + id: parse_tag + run: | + if [[ "${{ github.ref }}" == refs/tags/server/* ]]; then + TAG_PATH="${{ github.ref }}" + TAG_PATH="${TAG_PATH#refs/tags/}" + + IMAGE_TAG="${TAG_PATH#server/}" + + if [ -z "$IMAGE_TAG" ]; then + echo "failed to parse image tag from $TAG_PATH" >&2 + exit 1 + fi + + echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + else + echo "cannot parse tag" + exit 1 + fi + + - name: Build and push to registries + id: build + working-directory: server + env: + TAG: ${{ steps.parse_tag.outputs.image_tag }} + BUILD_METADATA_FILE: ${{ runner.temp }}/opensandbox-server-image-metadata.json + run: | + export GHCR_REPO="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/opensandbox" + chmod +x build.sh + ./build.sh + + DIGEST="$(jq -r '."containerimage.digest" // empty' "$BUILD_METADATA_FILE")" + if [[ -z "$DIGEST" ]]; then + echo "Unable to resolve image digest from $BUILD_METADATA_FILE" >&2 + cat "$BUILD_METADATA_FILE" >&2 + exit 1 + fi + + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + echo "dockerhub_image=docker.io/opensandbox/server" >> "$GITHUB_OUTPUT" + echo "acr_image=sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/server" >> "$GITHUB_OUTPUT" + echo "ghcr_image=$GHCR_REPO/server" >> "$GITHUB_OUTPUT" + + - name: Sign release images + if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + env: + DIGEST: ${{ steps.build.outputs.digest }} + DOCKERHUB_IMAGE: ${{ steps.build.outputs.dockerhub_image }} + ACR_IMAGE: ${{ steps.build.outputs.acr_image }} + GHCR_IMAGE: ${{ steps.build.outputs.ghcr_image }} + run: | + set -euo pipefail + cosign sign --yes "${DOCKERHUB_IMAGE}@${DIGEST}" "${ACR_IMAGE}@${DIGEST}" "${GHCR_IMAGE}@${DIGEST}" + + - name: Attest Docker Hub image + if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + uses: actions/attest@v4 + with: + subject-name: ${{ steps.build.outputs.dockerhub_image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest ACR image + if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + uses: actions/attest@v4 + with: + subject-name: ${{ steps.build.outputs.acr_image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: false + + - name: Attest GHCR image + if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server/v') && steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' + uses: actions/attest@v4 + with: + subject-name: ${{ steps.build.outputs.ghcr_image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + create-storage-record: false + + bump-server-chart: + if: startsWith(github.ref, 'refs/tags/server/v') + needs: publish-image + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Parse tag and set variables + id: parse_tag + run: | + TAG_PATH="${{ github.ref }}" + TAG_PATH="${TAG_PATH#refs/tags/}" + IMAGE_TAG="${TAG_PATH#server/}" + if [ -z "$IMAGE_TAG" ]; then + echo "failed to parse image tag from $TAG_PATH" >&2 + exit 1 + fi + echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: Bump server chart image tag + env: + GH_TOKEN: ${{ github.token }} + run: | + IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}" + if [[ "$IMAGE_TAG" =~ ^v ]]; then + VERSION="$IMAGE_TAG" + else + VERSION="v${IMAGE_TAG}" + fi + + chmod +x scripts/bump-server-chart.sh + ./scripts/bump-server-chart.sh "$VERSION" + + BRANCH="bump/server-chart-${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add kubernetes/charts/opensandbox-server/values.yaml + git diff --staged --quiet && echo "No changes to commit" && exit 0 + git commit -m "chore(chart): bump opensandbox-server image to ${VERSION}" + git push origin "$BRANCH" + + gh pr create \ + --title "chore(chart): bump opensandbox-server image to ${VERSION}" \ + --body "Auto-generated after publishing server image \`${VERSION}\` (tag \`${{ github.ref_name }}\`)." \ + --base "$(gh api repos/${{ github.repository }} --jq .default_branch)" diff --git a/.github/workflows/real-e2e.yml b/.github/workflows/real-e2e.yml new file mode 100644 index 0000000..0c6d635 --- /dev/null +++ b/.github/workflows/real-e2e.yml @@ -0,0 +1,566 @@ +name: Real E2E Tests + +permissions: + contents: read + pull-requests: read + +on: + workflow_dispatch: + inputs: + run_code_interpreter_e2e: + description: 'Run code-interpreter E2E tests' + required: false + type: boolean + default: false + pull_request: + branches: [ main ] + push: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: real-e2e + + python-e2e: + name: Python E2E (docker bridge) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: self-hosted + env: + UV_BIN: /home/admin/.local/bin + RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Clean Docker runner cache + run: bash scripts/ci-docker-cleanup.sh + + - name: Set up uv PATH and verify + run: | + echo "${UV_BIN}" >> "$GITHUB_PATH" + export PATH="${UV_BIN}:${PATH}" + uv --version + uv run python --version + + - name: Clean up previous E2E resources + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-redis || true + # Remove root-owned files from previous sandbox runs by mounting parent dir + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + docker image prune -f || true + + - name: Build local egress image + run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . + + - name: Start Redis for Python pool E2E + run: | + docker rm -f opensandbox-e2e-redis || true + docker run -d --name opensandbox-e2e-redis -p 127.0.0.1::6379 redis:7-alpine + REDIS_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "6379/tcp") 0).HostPort}}' opensandbox-e2e-redis)" + echo "OPENSANDBOX_TEST_REDIS_URL=redis://127.0.0.1:${REDIS_PORT}/0" >> "$GITHUB_ENV" + for i in {1..30}; do + if docker exec opensandbox-e2e-redis redis-cli ping | grep -q PONG; then + exit 0 + fi + sleep 1 + done + docker logs opensandbox-e2e-redis + exit 1 + + - name: Run tests + run: | + set -e + + # Create config file + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 8080 + api_key = "" + [log] + level = "INFO" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:local" + [egress] + image = "opensandbox/egress:local" + mode = "dns+nft" + [docker] + network_mode = "bridge" + [storage] + allowed_host_paths = ["/tmp/opensandbox-e2e"] + [renew_intent] + enabled = true + min_interval_seconds = 60 + EOF + + ./scripts/python-e2e.sh + + - name: Eval server logs + if: ${{ always() }} + run: cat server/server.log + + - name: Upload execd logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: execd-log-for-python-e2e + path: /tmp/opensandbox-e2e/logs/ + retention-days: 5 + + - name: Clean up after E2E + if: always() + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-redis || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + + java-e2e: + name: Java E2E (docker bridge) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: self-hosted + env: + UV_BIN: /home/admin/.local/bin + RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Clean Docker runner cache + run: bash scripts/ci-docker-cleanup.sh + + - name: Set up uv PATH and verify + run: | + echo "${UV_BIN}" >> "$GITHUB_PATH" + export PATH="${UV_BIN}:${PATH}" + uv --version + uv run python --version + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "8" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Clean up previous E2E resources + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-redis || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + docker image prune -f || true + + - name: Build local egress image + run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . + + - name: Start Redis for Java pool E2E + run: | + docker rm -f opensandbox-e2e-redis || true + docker run -d --name opensandbox-e2e-redis -p 127.0.0.1::6379 redis:7-alpine + REDIS_PORT="$(docker inspect -f '{{(index (index .NetworkSettings.Ports "6379/tcp") 0).HostPort}}' opensandbox-e2e-redis)" + echo "OPENSANDBOX_TEST_REDIS_URL=redis://127.0.0.1:${REDIS_PORT}/0" >> "$GITHUB_ENV" + for i in {1..30}; do + if docker exec opensandbox-e2e-redis redis-cli ping | grep -q PONG; then + exit 0 + fi + sleep 1 + done + docker logs opensandbox-e2e-redis + exit 1 + + - name: Run tests + env: + GRADLE_USER_HOME: ${{ github.workspace }}/.gradle-user-home + OPENSANDBOX_TEST_SECURE_ACCESS_VERIFIABLE: "false" + run: | + set -e + export GRADLE_OPTS="-Dorg.gradle.java.installations.auto-detect=true -Dorg.gradle.java.installations.auto-download=false -Dorg.gradle.java.installations.paths=${JAVA_HOME_8_X64},${JAVA_HOME_17_X64}" + + # Create config file + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 8080 + api_key = "" + [log] + level = "INFO" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:local" + [egress] + image = "opensandbox/egress:local" + mode = "dns+nft" + [docker] + network_mode = "bridge" + [storage] + allowed_host_paths = ["/tmp/opensandbox-e2e"] + EOF + + bash ./scripts/java-e2e.sh + + - name: Eval server logs + if: ${{ always() }} + run: cat server/server.log + + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v7 + with: + name: java-test-report + path: tests/java/build/reports/tests/test/ + retention-days: 5 + + - name: Upload execd logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: execd-log-for-java-e2e + path: /tmp/opensandbox-e2e/logs/ + retention-days: 5 + + - name: Clean up after E2E + if: always() + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-redis || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + + javascript-e2e: + name: JavaScript E2E (docker bridge) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: self-hosted + env: + UV_BIN: /home/admin/.local/bin + NODE_VERSION: "20.19.0" + RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Clean Docker runner cache + run: bash scripts/ci-docker-cleanup.sh + + - name: Set up uv PATH and verify + run: | + echo "${UV_BIN}" >> "$GITHUB_PATH" + export PATH="${UV_BIN}:${PATH}" + uv --version + uv run python --version + + - name: Set up Node.js + run: | + NODE_DIR="/home/admin/.local/node-v${NODE_VERSION}-linux-x64" + if [ -x "${NODE_DIR}/bin/node" ]; then + echo "Node.js ${NODE_VERSION} already cached" + else + echo "Downloading Node.js ${NODE_VERSION}..." + mkdir -p /home/admin/.local + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + | tar -xJ -C /home/admin/.local/ + fi + echo "${NODE_DIR}/bin" >> "$GITHUB_PATH" + export PATH="${NODE_DIR}/bin:${PATH}" + node --version + npm --version + + - name: Clean up previous E2E resources + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + docker image prune -f || true + + - name: Build local egress image + run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . + + - name: Run tests + run: | + set -e + + # Create config file (match other E2E jobs) + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 8080 + api_key = "" + [log] + level = "INFO" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:local" + [egress] + image = "opensandbox/egress:local" + mode = "dns+nft" + [docker] + network_mode = "bridge" + [storage] + allowed_host_paths = ["/tmp/opensandbox-e2e"] + EOF + + bash ./scripts/javascript-e2e.sh + + - name: Eval server logs + if: ${{ always() }} + run: cat server/server.log + + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v7 + with: + name: javascript-test-report + path: tests/javascript/build/test-results/junit.xml + retention-days: 5 + + - name: Upload execd logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: execd-log-for-js-e2e + path: /tmp/opensandbox-e2e/logs/ + retention-days: 5 + + - name: Clean up after E2E + if: always() + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + + csharp-e2e: + name: C# E2E (docker bridge) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: self-hosted + env: + UV_BIN: /home/admin/.local/bin + RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Clean Docker runner cache + run: bash scripts/ci-docker-cleanup.sh + + - name: Set up uv PATH and verify + run: | + echo "${UV_BIN}" >> "$GITHUB_PATH" + export PATH="${UV_BIN}:${PATH}" + uv --version + uv run python --version + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v5 + env: + DOTNET_INSTALL_DIR: /home/admin/.local/dotnet + with: + dotnet-version: "10.0.x" + + - name: Clean up previous E2E resources + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + docker image prune -f || true + + - name: Build local egress image + run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . + + - name: Run tests + run: | + set -e + + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 8080 + api_key = "" + [log] + level = "INFO" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:local" + [egress] + image = "opensandbox/egress:local" + mode = "dns+nft" + [docker] + network_mode = "bridge" + [storage] + allowed_host_paths = ["/tmp/opensandbox-e2e"] + EOF + + bash ./scripts/csharp-e2e.sh + + - name: Eval server logs + if: ${{ always() }} + run: cat server/server.log + + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v7 + with: + name: csharp-test-report + path: tests/csharp/build/test-results/ + retention-days: 5 + + - name: Upload execd logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: execd-log-for-csharp-e2e + path: /tmp/opensandbox-e2e/logs/ + retention-days: 5 + + - name: Clean up after E2E + if: always() + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + + go-e2e: + name: Go E2E (docker bridge) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: self-hosted + env: + UV_BIN: /home/admin/.local/bin + HOME: /home/admin/actions-runner + GOCACHE: /home/admin/actions-runner/_temp/go-build + GOMODCACHE: /home/admin/actions-runner/_temp/go-mod-cache + RUN_CODE_INTERPRETER_E2E: ${{ github.event_name == 'workflow_dispatch' && inputs.run_code_interpreter_e2e == true && 'true' || 'false' }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Clean Docker runner cache + run: bash scripts/ci-docker-cleanup.sh + + - name: Set up uv PATH and verify + run: | + echo "${UV_BIN}" >> "$GITHUB_PATH" + export PATH="${UV_BIN}:${PATH}" + uv --version + uv run python --version + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.24" + cache: false + + - name: Clean up previous E2E resources + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + docker image prune -f || true + + - name: Build local egress image + run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . + + - name: Run tests + run: | + set -e + + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 8080 + api_key = "" + [log] + level = "INFO" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:local" + [egress] + image = "opensandbox/egress:local" + mode = "dns+nft" + [docker] + network_mode = "bridge" + [storage] + allowed_host_paths = ["/tmp/opensandbox-e2e"] + EOF + + bash ./scripts/go-e2e.sh + + - name: Eval server logs + if: ${{ always() }} + run: cat server/server.log + + - name: Upload Test Report + if: always() + uses: actions/upload-artifact@v7 + with: + name: go-test-report + path: tests/go/reports/ + retention-days: 5 + + - name: Upload execd logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: execd-log-for-go-e2e + path: /tmp/opensandbox-e2e/logs/ + retention-days: 5 + + - name: Clean up after E2E + if: always() + run: | + docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true + docker rm -f opensandbox-e2e-credential-vault-target || true + docker ps -aq --filter "label=opensandbox.e2e=credential-vault" | xargs -r docker rm -f || true + docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true + + required: + name: Real E2E CI + if: ${{ always() && github.event_name == 'pull_request' }} + needs: [changes, python-e2e, java-e2e, javascript-e2e, csharp-e2e, go-e2e] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + PYTHON_RESULT: ${{ needs.python-e2e.result }} + JAVA_RESULT: ${{ needs.java-e2e.result }} + JAVASCRIPT_RESULT: ${{ needs.javascript-e2e.result }} + CSHARP_RESULT: ${{ needs.csharp-e2e.result }} + GO_RESULT: ${{ needs.go-e2e.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$PYTHON_RESULT" == "success" && "$JAVA_RESULT" == "success" && "$JAVASCRIPT_RESULT" == "success" && "$CSHARP_RESULT" == "success" && "$GO_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$PYTHON_RESULT" == "skipped" && "$JAVA_RESULT" == "skipped" && "$JAVASCRIPT_RESULT" == "skipped" && "$CSHARP_RESULT" == "skipped" && "$GO_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/release-generic.yml b/.github/workflows/release-generic.yml new file mode 100644 index 0000000..dfb9ae3 --- /dev/null +++ b/.github/workflows/release-generic.yml @@ -0,0 +1,190 @@ +name: Generic Release + +on: + workflow_dispatch: + inputs: + target: + description: "Release target key" + required: true + type: choice + options: + - js/sandbox + - js/code-interpreter + - python/sandbox + - python/code-interpreter + - python/mcp/sandbox + - java/sandbox + - java/code-interpreter + - csharp/sandbox + - csharp/code-interpreter + - sdks/sandbox/go + - cli + - server + - docker/execd + - docker/code-interpreter + - docker/ingress + - docker/egress + - k8s/controller + - k8s/task-executor + - helm/opensandbox + - helm + version: + description: "Version to release (e.g. 1.0.5 or v0.3.0)" + required: true + type: string + from_tag: + description: "Optional previous tag override" + required: false + type: string + no_path_filter: + description: "Disable default target path filtering" + required: true + default: false + type: boolean + extra_paths: + description: "Optional extra paths (comma-separated)" + required: false + type: string + initial_release: + description: "Allow release without previous tag" + required: true + default: false + type: boolean + dry_run: + description: "Preview only, no side effects" + required: true + default: true + type: boolean + +permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + +jobs: + release-preflight: + uses: ./.github/workflows/release-preflight.yml + with: + require_approval: ${{ inputs.dry_run == false }} + + release: + needs: release-preflight + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Ensure script executable + run: chmod +x scripts/release/create-release.sh + + - name: Run generic release script + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ARGS=( + --target "${{ inputs.target }}" + --version "${{ inputs.version }}" + ) + + if [[ -n "${{ inputs.from_tag }}" ]]; then + ARGS+=(--from-tag "${{ inputs.from_tag }}") + fi + + if [[ "${{ inputs.no_path_filter }}" == "true" ]]; then + ARGS+=(--no-path-filter) + fi + + if [[ -n "${{ inputs.extra_paths }}" ]]; then + IFS=',' read -r -a EXTRA_PATHS <<< "${{ inputs.extra_paths }}" + for path in "${EXTRA_PATHS[@]}"; do + trimmed="$(echo "$path" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ -n "$trimmed" ]]; then + ARGS+=(--path "$trimmed") + fi + done + fi + + if [[ "${{ inputs.initial_release }}" == "true" ]]; then + ARGS+=(--initial-release) + fi + + if [[ "${{ inputs.dry_run }}" == "true" ]]; then + ARGS+=(--dry-run) + fi + + scripts/release/create-release.sh "${ARGS[@]}" + + - name: Verify release tag on origin + if: ${{ inputs.dry_run == false }} + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + local_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")" + remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}^{}" | awk 'NR == 1 { print $1 }')" + + if [[ -z "$remote_commit" ]]; then + remote_commit="$(git ls-remote origin "refs/tags/${RELEASE_TAG}" | awk 'NR == 1 { print $1 }')" + fi + + if [[ -z "$remote_commit" ]]; then + echo "::error::Release tag '${RELEASE_TAG}' does not exist on origin. Have an authorized release manager push the tag before publishing source artifacts." + exit 1 + fi + + if [[ "$local_commit" != "$remote_commit" ]]; then + echo "::error::Local release tag '${RELEASE_TAG}' resolves to ${local_commit}, but origin resolves to ${remote_commit}. Refusing to publish source artifacts." + exit 1 + fi + + - name: Create release source archive + if: ${{ inputs.dry_run == false }} + id: source_archive + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + SAFE_TAG="$(printf '%s' "$RELEASE_TAG" | tr '/:' '--')" + ARCHIVE_NAME="opensandbox-${SAFE_TAG}.tar.gz" + ARCHIVE_DIR="dist/release-source" + + mkdir -p "$ARCHIVE_DIR" + git archive \ + --format=tar.gz \ + --prefix="opensandbox-${SAFE_TAG}/" \ + -o "${ARCHIVE_DIR}/${ARCHIVE_NAME}" \ + "$RELEASE_TAG" + + ( + cd "$ARCHIVE_DIR" + sha256sum "$ARCHIVE_NAME" > SHA256SUMS + ) + + echo "archive_path=${ARCHIVE_DIR}/${ARCHIVE_NAME}" >> "$GITHUB_OUTPUT" + echo "checksums_path=${ARCHIVE_DIR}/SHA256SUMS" >> "$GITHUB_OUTPUT" + + - name: Attest source release artifacts + if: ${{ inputs.dry_run == false }} + uses: actions/attest@v4 + with: + subject-path: | + ${{ steps.source_archive.outputs.archive_path }} + ${{ steps.source_archive.outputs.checksums_path }} + + - name: Upload source release artifacts + if: ${{ inputs.dry_run == false }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + gh release upload "$RELEASE_TAG" \ + "${{ steps.source_archive.outputs.archive_path }}" \ + "${{ steps.source_archive.outputs.checksums_path }}" \ + --clobber diff --git a/.github/workflows/release-preflight.yml b/.github/workflows/release-preflight.yml new file mode 100644 index 0000000..ed5cb76 --- /dev/null +++ b/.github/workflows/release-preflight.yml @@ -0,0 +1,36 @@ +name: Release Preflight + +on: + workflow_call: + inputs: + require_approval: + description: Require approval through the release environment + required: false + default: true + type: boolean + +permissions: + contents: read + +jobs: + verify-source: + name: Verify release source + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify release commit is reachable from main + run: scripts/release/verify-release-ref.sh + + approve-release: + name: Approve release + if: ${{ inputs.require_approval }} + needs: verify-source + runs-on: ubuntu-latest + environment: release + steps: + - name: Record approval + run: echo "Release environment approval granted." diff --git a/.github/workflows/runner-system-prune.yml b/.github/workflows/runner-system-prune.yml new file mode 100644 index 0000000..5af3b84 --- /dev/null +++ b/.github/workflows/runner-system-prune.yml @@ -0,0 +1,25 @@ +name: Runners prune + +permissions: + contents: read + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + system-prune: + name: Runner system prune + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + runner: [1, 2, 3, 4, 5, 6, 7, 8] + steps: + - name: Docker system prune + run: | + docker system prune -a -f + diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml new file mode 100644 index 0000000..f70bce1 --- /dev/null +++ b/.github/workflows/sdk-tests.yml @@ -0,0 +1,461 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: SDK Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + paths: + - "cli/**" + - "sdks/**" + - "specs/**" + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: sdk + + cli-quality: + name: CLI Quality + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Install dependencies + working-directory: cli + run: | + uv sync + + - name: Run ruff + working-directory: cli + run: | + uv run ruff check + + - name: Run pyright + working-directory: cli + run: | + uv run pyright + + cli-tests: + name: CLI Tests + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Install dependencies + working-directory: cli + run: | + uv sync + + - name: Run tests + working-directory: cli + run: | + mkdir -p reports + uv run pytest tests/ -v --junitxml=reports/junit.xml + + - name: Upload CLI reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-reports + path: | + cli/reports/** + + python-sdk-quality: + name: Python SDK Quality (${{ matrix.package_name }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package_name: sandbox + package_dir: sdks/sandbox/python + - package_name: code-interpreter + package_dir: sdks/code-interpreter/python + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Install dependencies + working-directory: ${{ matrix.package_dir }} + run: | + uv sync + + - name: Generate API + if: matrix.package_name == 'sandbox' + working-directory: sdks/sandbox/python + run: | + uv run python scripts/generate_api.py + + - name: Run ruff + working-directory: ${{ matrix.package_dir }} + run: | + uv run ruff check + + - name: Run pyright + working-directory: ${{ matrix.package_dir }} + run: | + uv run pyright + + python-sdk-tests: + name: Python SDK Tests (${{ matrix.package_name }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package_name: sandbox + package_dir: sdks/sandbox/python + coverage_target: src/opensandbox + - package_name: code-interpreter + package_dir: sdks/code-interpreter/python + coverage_target: src/code_interpreter + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Install dependencies + working-directory: ${{ matrix.package_dir }} + run: | + uv sync + + - name: Generate API + if: matrix.package_name == 'sandbox' + working-directory: sdks/sandbox/python + run: | + uv run python scripts/generate_api.py + + - name: Run tests + working-directory: ${{ matrix.package_dir }} + run: | + mkdir -p reports + uv run pytest tests/ -v \ + --junitxml=reports/junit.xml \ + --cov=${{ matrix.coverage_target }} \ + --cov-report=term-missing \ + --cov-report=xml:reports/coverage.xml + + - name: Upload Python reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-${{ matrix.package_name }}-reports + path: | + ${{ matrix.package_dir }}/reports/** + + javascript-sdk-quality: + name: JavaScript SDK Quality And Tests (${{ matrix.package_name }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package_name: sandbox + package_dir: sdks/sandbox/javascript + - package_name: code-interpreter + package_dir: sdks/code-interpreter/javascript + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + cache-dependency-path: sdks/pnpm-lock.yaml + + - name: Install dependencies + working-directory: sdks + run: | + pnpm install --frozen-lockfile + + - name: Build JavaScript SDK dependencies + working-directory: sdks + run: | + pnpm run build:js + + - name: Run eslint + working-directory: ${{ matrix.package_dir }} + run: | + pnpm run lint + + - name: Run TypeScript typecheck + working-directory: ${{ matrix.package_dir }} + run: | + pnpm run typecheck + + - name: Run tests + working-directory: ${{ matrix.package_dir }} + run: | + mkdir -p reports + node --test tests/*.test.mjs > reports/test-output.txt + + - name: Upload JavaScript reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: javascript-${{ matrix.package_name }}-reports + path: | + ${{ matrix.package_dir }}/reports/** + + kotlin-sdk-quality: + name: Kotlin SDK Quality And Tests (${{ matrix.package_name }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package_name: sandbox + package_dir: sdks/sandbox/kotlin + test_task: :sandbox:test + gradle_args: "" + - package_name: code-interpreter + package_dir: sdks/code-interpreter/kotlin + test_task: :code-interpreter:test + gradle_args: "-PuseMavenLocal" + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Publish sandbox SDK to Maven local + if: matrix.package_name == 'code-interpreter' + working-directory: sdks/sandbox/kotlin + run: | + ./gradlew publishToMavenLocal + + - name: Run quality checks and tests + working-directory: ${{ matrix.package_dir }} + run: | + ./gradlew spotlessCheck ${{ matrix.test_task }} ${{ matrix.gradle_args }} + + - name: Upload Kotlin reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: kotlin-${{ matrix.package_name }}-reports + path: | + ${{ matrix.package_dir }}/**/build/test-results/test/** + ${{ matrix.package_dir }}/**/build/reports/tests/test/** + + csharp-sdk-quality: + name: C# SDK Quality And Tests (${{ matrix.package_name }}) + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package_name: sandbox + package_dir: sdks/sandbox/csharp + solution: OpenSandbox.sln + test_project: tests/OpenSandbox.Tests/OpenSandbox.Tests.csproj + - package_name: code-interpreter + package_dir: sdks/code-interpreter/csharp + solution: OpenSandbox.CodeInterpreter.sln + test_project: tests/OpenSandbox.CodeInterpreter.Tests/OpenSandbox.CodeInterpreter.Tests.csproj + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Build with analyzers + working-directory: ${{ matrix.package_dir }} + run: | + dotnet build ${{ matrix.solution }} --configuration Release /warnaserror + + - name: Run tests with reports + working-directory: ${{ matrix.package_dir }} + run: | + dotnet test ${{ matrix.test_project }} \ + --configuration Release \ + --no-build \ + --logger "trx;LogFileName=test-results.trx" \ + --results-directory TestResults \ + --collect:"XPlat Code Coverage" + + - name: Upload C# reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: csharp-${{ matrix.package_name }}-reports + path: | + ${{ matrix.package_dir }}/TestResults/** + + go-sdk-quality: + name: Go SDK Quality And Tests + needs: changes + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.20" + + - name: Check gofmt + working-directory: sdks/sandbox/go + run: | + files="$(gofmt -l .)" + if [ -n "$files" ]; then + echo "$files" + exit 1 + fi + + - name: Run go vet + working-directory: sdks/sandbox/go + run: | + go vet ./... + + - name: Run tests with race detector + working-directory: sdks/sandbox/go + run: | + mkdir -p reports + go test -v -race -coverprofile=reports/coverage.out ./... + go tool cover -func=reports/coverage.out + + - name: Upload Go reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: go-sdk-reports + path: | + sdks/sandbox/go/reports/** + + required: + name: SDK CI + if: ${{ always() && github.event_name == 'pull_request' }} + needs: + - changes + - cli-quality + - cli-tests + - python-sdk-quality + - python-sdk-tests + - javascript-sdk-quality + - kotlin-sdk-quality + - csharp-sdk-quality + - go-sdk-quality + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + CLI_QUALITY_RESULT: ${{ needs.cli-quality.result }} + CLI_TESTS_RESULT: ${{ needs.cli-tests.result }} + PYTHON_QUALITY_RESULT: ${{ needs.python-sdk-quality.result }} + PYTHON_TESTS_RESULT: ${{ needs.python-sdk-tests.result }} + JAVASCRIPT_RESULT: ${{ needs.javascript-sdk-quality.result }} + KOTLIN_RESULT: ${{ needs.kotlin-sdk-quality.result }} + CSHARP_RESULT: ${{ needs.csharp-sdk-quality.result }} + GO_RESULT: ${{ needs.go-sdk-quality.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$CLI_QUALITY_RESULT" == "success" && "$CLI_TESTS_RESULT" == "success" && "$PYTHON_QUALITY_RESULT" == "success" && "$PYTHON_TESTS_RESULT" == "success" && "$JAVASCRIPT_RESULT" == "success" && "$KOTLIN_RESULT" == "success" && "$CSHARP_RESULT" == "success" && "$GO_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$CLI_QUALITY_RESULT" == "skipped" && "$CLI_TESTS_RESULT" == "skipped" && "$PYTHON_QUALITY_RESULT" == "skipped" && "$PYTHON_TESTS_RESULT" == "skipped" && "$JAVASCRIPT_RESULT" == "skipped" && "$KOTLIN_RESULT" == "skipped" && "$CSHARP_RESULT" == "skipped" && "$GO_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/server-test.yml b/.github/workflows/server-test.yml new file mode 100644 index 0000000..8e82068 --- /dev/null +++ b/.github/workflows/server-test.yml @@ -0,0 +1,150 @@ +name: Server Tests + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + uses: ./.github/workflows/detect-changes.yml + with: + area: server + + test: + needs: changes + if: needs.changes.outputs.relevant == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + run: | + pip install uv + + - name: Run tests + run: | + cd server + uv sync --all-groups + uv run ruff check + mkdir -p reports + uv run pytest \ + --cov=opensandbox_server \ + --cov-report=term \ + --cov-report=xml:reports/coverage.xml \ + --cov-fail-under=80 + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: server-coverage-${{ matrix.os }} + path: server/reports/coverage.xml + + docker-smoke: + needs: changes + if: needs.changes.outputs.relevant == 'true' + strategy: + matrix: + network: [host, bridge] + runs-on: ubuntu-latest + env: + OPENSANDBOX_INSECURE_SERVER: YES + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install uv + run: | + pip install uv + + - name: Set up Docker + run: | + docker --version + + - name: Run smoke test + run: | + set -e + cd server + uv sync --all-groups + + # Create config file + cat < ~/.sandbox.toml + [server] + host = "127.0.0.1" + port = 32888 + api_key = "" + [log] + level = "INFO" + [runtime] + type = "docker" + execd_image = "opensandbox/execd:latest" + [egress] + image = "opensandbox/egress:latest" + [docker] + network_mode = "${{ matrix.network }}" + [storage] + allowed_host_paths = ["/tmp/opensandbox-e2e"] + EOF + + # Start server in background + uv run python -m opensandbox_server.main > app.log 2>&1 & + + # Wait for server to start + sleep 10 + + # Run smoke test + chmod +x tests/smoke.sh + ./tests/smoke.sh + - name: Show logs + if: always() + run: | + cat server/app.log + + required: + name: Server CI + if: always() + needs: [changes, test, docker-smoke] + runs-on: ubuntu-latest + steps: + - name: Verify required jobs + env: + RELEVANT: ${{ needs.changes.outputs.relevant }} + CHANGES_RESULT: ${{ needs.changes.result }} + TEST_RESULT: ${{ needs.test.result }} + DOCKER_SMOKE_RESULT: ${{ needs.docker-smoke.result }} + run: | + if [[ "$CHANGES_RESULT" != "success" ]]; then + echo "Change detection failed: $CHANGES_RESULT" + exit 1 + fi + if [[ "$RELEVANT" == "true" ]]; then + [[ "$TEST_RESULT" == "success" && "$DOCKER_SMOKE_RESULT" == "success" ]] + else + [[ "$RELEVANT" == "false" && "$TEST_RESULT" == "skipped" && "$DOCKER_SMOKE_RESULT" == "skipped" ]] + fi diff --git a/.github/workflows/verify-license.yml b/.github/workflows/verify-license.yml new file mode 100644 index 0000000..b806cd7 --- /dev/null +++ b/.github/workflows/verify-license.yml @@ -0,0 +1,26 @@ +name: Verify License Headers + +on: + pull_request: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify-license: + runs-on: self-hosted + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run license verification + run: | + chmod +x scripts/verify-license.sh + ./scripts/verify-license.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24d9ecf --- /dev/null +++ b/.gitignore @@ -0,0 +1,279 @@ +# IDE and Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Go +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Java/Kotlin +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs +hs_err_pid* +replay_pid* + +# Gradle +.gradle/ +build/ +!**/gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Node.js +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +public +!docs/public/ +!docs/public/CNAME + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# Docker +*.pid +*.seed +*.pid.lock + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp +*~ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# API keys and secrets +secrets/ +*.pem +*.key +*.crt +*.p12 +*.pfx + +# Generated API documentation +docs/generated/ +docs/.vitepress/generated/ +docs/.vitepress/dist/ +docs/.vitepress/cache/ +apidocs/ + +# Test results +test-results/ +coverage/ +*.coverage +.nyc_output + +# Backup files +*.bak +*.backup +*.old + +# Flattened POM files (Maven) +.flattened-pom.xml + +# Kotlin +*.kotlin_module + +# JetBrains specific +.idea/ +*.iml +*.ipr +*.iws +out/ + +# Eclipse specific +.project +.classpath +.settings/ +bin/ + +# NetBeans specific +nbproject/ +nbbuild/ +nbdist/ +.nb-gradle/ + +# Generated files +generated/ +**/generated/** + +# gVisor runtime binaries (downloaded dynamically) +kubernetes/test/kind/gvisor/runsc +kubernetes/test/kind/gvisor/containerd-shim-runsc-v1 +bin/ +obj/ + +.qoder/ +/.vs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..45f6bdc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +# Minimal cross-language pre-commit hooks +# Install: pip install pre-commit && pre-commit install +# Run once on all files: pre-commit run --all-files + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + - id: check-merge-conflict + - id: check-yaml + - id: detect-private-key + + # Language-specific formatters/linters can be added later, for example: + # - repo: local + # hooks: + # - id: gofmt + # name: gofmt + # entry: gofmt + # language: system + # types: [go] + # - id: ruff + # name: ruff + # entry: ruff check + # language: system + # types: [python] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..033cda4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# OpenSandbox AGENTS + +Use this file as the root router for the monorepo. Prefer the nearest `AGENTS.md` in the directory tree for task-specific instructions. + +## Repository Map + +- `server/`: FastAPI lifecycle control plane, Docker/Kubernetes runtime integration, snapshot metadata, and server tests +- `components/execd/`: in-sandbox execution daemon +- `components/egress/`: per-sandbox network egress policy sidecar +- `components/ingress/`: ingress gateway and endpoint routing +- `components/internal/`: shared Go helpers used by runtime components +- `sdks/`: sandbox, code-interpreter, and MCP SDKs plus generated clients +- `specs/`: public OpenAPI contracts and examples +- `kubernetes/`: Kubernetes operator, CRDs, task-executor, Helm charts, and Kind e2e tests +- `cli/`: `osb` command-line client and bundled CLI skills +- `tests/`: cross-language end-to-end SDK tests +- `docs/`, `examples/`, `sandboxes/`, `oseps/`: documentation, samples, images/environments, and proposals + +## Routing + +- For `server/**`, or lifecycle server behavior, sandbox creation flow, or user-visible server config, read `server/AGENTS.md`. +- For `sdks/**`, or SDK generation, handwritten adapters, or cross-language SDK alignment, read `sdks/AGENTS.md`. +- For `specs/**`, or API contract, schema, or example changes, read `specs/AGENTS.md`. +- For `kubernetes/**`, or CRDs, controller behavior, task execution, Helm/Kustomize deployment, pool scheduling, pause/resume snapshots, or Kind e2e tests, read `kubernetes/AGENTS.md`. +- For cross-cutting changes spanning spec, server, and SDKs, start with `specs/AGENTS.md` and then read affected consumer guides. +- For runtime component changes under `components/**`, read the nearest `README.md` or `DEVELOPMENT.md`; keep component APIs aligned with `specs/` and SDK consumers. +- For CLI changes under `cli/**`, read `cli/README.md` and verify command help/output behavior alongside unit tests. +- For cross-language e2e tests under `tests/**`, read the language-local README and keep test assumptions aligned with current server and SDK behavior. +- For areas without a local `AGENTS.md`, use the nearest `README.md`, `DEVELOPMENT.md`, and CI workflow as the next source of truth. + +## Working Principles + +- Think before coding: state assumptions, surface ambiguity, and ask or push back when the request has conflicting interpretations. +- Simplicity first: implement the smallest solution that satisfies the request; avoid speculative features, one-off abstractions, and unnecessary configurability. +- Surgical changes: touch only files and lines needed for the task, match local style, and do not refactor or delete unrelated pre-existing code. +- Goal-driven execution: translate non-trivial work into verifiable success criteria, add or update focused tests when behavior changes, and loop until checks pass or blockers are clear. + +## Guardrails + +Always: + +- Keep changes focused on the user request. +- Treat `specs/*` as public contract sources. +- Keep spec, implementation, SDKs, docs, examples, config, and CLI behavior aligned when user-visible behavior changes. +- When changing `specs/*`, also update or verify affected server, SDK, docs, and release outputs when practical. +- When changing CRDs or Kubernetes public behavior, update or verify generated manifests, Helm/Kustomize deployment output, server Kubernetes integration, and docs when practical. +- Prefer additive, backward-compatible changes for public interfaces. +- Regenerate derived outputs when the source-of-truth file changes. +- Update tests when behavior changes or bugs are fixed. +- Mention unrun or blocked verification in the final handoff. +- Prefer file-scoped or package-scoped checks before full-suite validation. + +Ask first: + +- Breaking public API, SDK, config, protocol, or CLI changes +- Breaking CRD, annotation, label, Helm values, or Kubernetes deployment changes +- Intentional drift between a public contract and its implementation +- User-visible config or behavior changes without a clear migration story + +Never: + +- Edit generated output as the only fix. +- Mix unrelated component work into the same change. + +## Documentation Rules + +### Content ownership — single source of truth + +| Content type | Source of truth | Rule | +|---|---|---| +| User and operations docs | `docs/` | Keep long-form docs here | +| Root README | repo root `README.md` | GitHub homepage | +| SDK, CLI, Helm, and other publishable package READMEs | package directory | Keep install, quick start, and package entry points | +| Non-publishable component/module READMEs | component/module directory | Keep minimal pointers to `docs/` when a docs page exists | +| Examples | `docs/examples/` + runnable code under `examples/` | Put docs in `docs/examples/`; keep example READMEs as thin pointers | +| OSEPs | `oseps/` | `docs/community/oseps.md` only indexes GitHub proposals | +| CONTRIBUTING, CODE_OF_CONDUCT, DEVELOPMENT | repo root / component directory | `docs/community/` links to them, does not duplicate them | + +**When modifying user-visible or operations-visible behavior**: update `docs/` first. + +**When editing READMEs**: avoid long-form docs outside root or publishable package READMEs. + +**When adding examples**: keep runnable code under `examples/`, and document it under `docs/examples/`. + +**When linking from README files to docs**: prefer repository-relative `docs/*.md` links so links match the checked-out branch or tag. Use `https://open-sandbox.ai` as a public site entry point, not as the only source link from versioned code directories. + +**When handling localized READMEs**: do not add new localized copies for SDKs, examples, or non-special modules. + +### Docs site structure + +``` +docs/ + getting-started/ # Quick start, installation, configuration + architecture/ # Architecture overview, network design + guides/ # Feature guides (credential vault, secure container, etc.) + sdks/ # SDK reference (one page per language per SDK) + components/ # Server, execd, ingress, egress + kubernetes/ # Kubernetes operator and deployment + api/ # OpenAPI spec reference + cli/ # CLI reference + examples/ # One page per example + community/ # Contributing, code of conduct, OSEPs, releases + reference/ # Migration guides +``` + +### Docs conventions + +- Engine: VitePress. Config is a static `config.mts` with no build-time code generation. +- All images go in `docs/public/images/`, referenced as `/images/filename` in markdown. +- Every page must have YAML frontmatter with `title` and `description`. +- Internal links use VitePress absolute paths (e.g., `/sdks/python`, `/guides/credential-vault`). +- Links to source code or specs use full GitHub URLs. +- Use VitePress custom containers (`::: tip`, `::: warning`, `::: info`) and code groups where appropriate. +- Build and verify: `cd docs && pnpm docs:build` — must complete with zero errors. +- The README.md in `docs/` is for docs-site contributors only (how to run dev server), and must stay excluded from the published site. + +## Review Focus + +- Prioritize breaking changes in specs, SDK interfaces, config, CLI behavior, and protocols. +- Flag protocol changes that are unnecessary, inconsistent, or hard to implement. +- Flag changes that break source-of-truth boundaries or intended layering. +- Call out missing tests and compatibility risks explicitly. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..415b358 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# OpenSandbox Claude Guide + +See `AGENTS.md` for all rules, routing, and conventions. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..16be552 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of Conduct + +We are committed to a welcoming, safe, and respectful community. + +## Expected Behavior +- Be respectful and inclusive. +- Assume good intent; seek to understand. +- Provide constructive feedback; critique code, not people. +- Follow project guidelines and security practices. + +## Unacceptable Behavior +- Harassment, personal attacks, or discriminatory language. +- Publishing private information without consent. +- Disruptive or aggressive behavior in any project space. + +## Scope +This Code applies to all project spaces, including issues, pull requests, discussions, chat, and events. + +## Reporting +Report incidents to: **conduct@opensandbox.io**. Include as much detail as possible (what happened, when/where, links, screenshots if applicable). + +## Enforcement +Maintainers will investigate in good faith and may take appropriate action, including warnings, temporary bans, or removal from the community. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8949e7d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,675 @@ +# Contributing to OpenSandbox + +Thank you for your interest in contributing to OpenSandbox! This guide will help you get started with contributing to the project, whether you're fixing bugs, adding features, improving documentation, or helping in other ways. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Environment Setup](#development-environment-setup) +- [Project Structure](#project-structure) +- [Development Workflow](#development-workflow) +- [Coding Standards](#coding-standards) +- [Testing Guidelines](#testing-guidelines) +- [Submitting Contributions](#submitting-contributions) +- [Communication Channels](#communication-channels) + +## Code of Conduct + +OpenSandbox adheres to a [Code of Conduct](CODE_OF_CONDUCT.md) that we expect all contributors to follow. Please read it before contributing to ensure a welcoming and inclusive environment for everyone. + +## Getting Started + +### Ways to Contribute + +There are many ways to contribute to OpenSandbox: + +- **Report Bugs**: Submit detailed bug reports through [GitHub Issues](https://github.com/opensandbox-group/OpenSandbox/issues) +- **Suggest Features**: Propose new features or improvements +- **Write Code**: Fix bugs, implement features, or improve performance +- **Improve Documentation**: Enhance README files, write tutorials, or fix typos +- **Write Tests**: Add test coverage or improve existing tests +- **Review Pull Requests**: Help review and test others' contributions +- **Answer Questions**: Help other users in GitHub Discussions or Issues + +### Before You Start + +1. **Search Existing Issues**: Check if your bug report or feature request already exists +2. **Check Roadmap**: Review the project roadmap to see if your idea aligns with project goals +3. **Discuss Major Changes**: For significant changes, open an issue first or submit an [OSEP](oseps/README.md) to discuss your approach +4. **Review Architecture**: Read [Architecture Overview](docs/architecture/) to understand the system design + +## Development Environment Setup + +### Prerequisites + +Different components have different requirements: + +#### For Server (Python) + +- **Python 3.10+** +- **uv** - Python package manager ([installation guide](https://github.com/astral-sh/uv)) +- **Docker** - For running sandboxes locally + +#### For execd (Go) + +- **Go 1.24+** +- **Make** - Build automation (optional) +- **Docker** - For building container images + +#### For SDKs + +- **Python SDK**: Python 3.10+, uv +- **Java/Kotlin SDK**: JDK 17+, Gradle + +### Quick Setup + +#### Server Development + +```bash +# Navigate to server directory +cd server + +# Install dependencies +uv sync + +# Copy example configuration from the source tree +cp server/opensandbox_server/examples/example.config.toml ~/.sandbox.toml + +# Edit configuration for development +# Set [log] level = "DEBUG" and [server] api_key +nano ~/.sandbox.toml + +# Run server +uv run python -m opensandbox_server.main +``` + +See [server/DEVELOPMENT.md](server/DEVELOPMENT.md) for detailed server development guide. + +#### execd Development + +```bash +# Navigate to execd directory +cd components/execd + +# Download dependencies +go mod download + +# Build execd +go build -o bin/execd . + +# Run execd (requires Jupyter Server) +./bin/execd --jupyter-host=http://localhost:8888 --port=44772 +``` + +See [components/execd/DEVELOPMENT.md](components/execd/DEVELOPMENT.md) for detailed execd development guide. + +#### SDK Development + +**Python SDK:** + +```bash +cd sdks/sandbox/python +uv sync +uv run pytest +``` + +**Java/Kotlin SDK:** + +```bash +cd sdks/sandbox/kotlin +./gradlew build +./gradlew test +``` + +## Project Structure + +``` +OpenSandbox/ +├── sdks/ # Multi-language SDKs +│ ├── code-interpreter/ # Code Interpreter SDK (Python, Kotlin) +│ └── sandbox/ # Sandbox base SDK (Python, Kotlin) +├── specs/ # OpenAPI specifications +│ ├── execd-api.yaml # Execution API spec +│ └── sandbox-lifecycle.yml # Lifecycle API spec +├── server/ # Sandbox server (Python/FastAPI) +├── components/ +│ └── execd/ # Execution daemon (Go/Beego) +├── sandboxes/ # Sandbox implementations +│ └── code-interpreter/ # Code Interpreter sandbox +├── examples/ # Example integrations +├── docs/ # Documentation +├── tests/ # Cross-component tests +│ └── e2e/ # End-to-end tests +└── scripts/ # Build and utility scripts +``` + +## Development Workflow + +### Enhancement Proposals (OSEP) + +For major features, architectural changes, or modifications to the core API/security model, we follow the **OSEP (OpenSandbox Enhancement Proposals)** process. + +Please read the [OSEP README](oseps/README.md) to understand when an OSEP is required and how to submit one. Small bug fixes and minor improvements do not require an OSEP. + +### Branching Strategy + +- **main**: Stable production branch +- **feature/[name]**: New features +- **fix/[name]**: Bug fixes +- **docs/[name]**: Documentation updates +- **refactor/[name]**: Code refactoring +- **test/[name]**: Test additions or improvements + +### Creating a Feature Branch + +```bash +# Update main branch +git checkout main +git pull origin main + +# Create feature branch +git checkout -b feature/my-awesome-feature + +# Make your changes +# ... + +# Commit your changes +git add . +git commit -m "feat: add my awesome feature" + +# Push to your fork +git push origin feature/my-awesome-feature +``` + +### Commit Message Format + +We follow [Conventional Commits](https://www.conventionalcommits.org/) specification: + +``` +(): + +[optional body] + +[optional footer] +``` + +**Types:** + +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting, no logic change) +- `refactor`: Code refactoring +- `test`: Adding or updating tests +- `chore`: Build process, dependencies, or tooling changes +- `perf`: Performance improvements +- `ci`: CI/CD changes + +**Examples:** + +``` +feat(server): add Kubernetes runtime support +fix(execd): resolve memory leak in session cleanup +docs(sdk): add Python SDK usage examples +test(server): add integration tests for Docker runtime +refactor(sdk): simplify filesystem API +``` + +### Making Changes + +1. **Write Clean Code**: Follow project coding standards (see below) +2. **Add Tests**: Ensure your changes are covered by tests +3. **Update Documentation**: Update relevant documentation files +4. **Test Locally**: Run all tests and ensure they pass +5. **Check Linting**: Run linters and fix any issues + +## Coding Standards + +Contributions are required to generally comply with the coding standards for +the language and component they touch. Generated files are excluded where the +local tool configuration excludes them; update the source specification or +generator and regenerate those files instead of hand-editing generated output. + +### Required Style Guides + +| Area | Primary language | Required style guide | +| --- | --- | --- | +| Server, CLI, Python SDKs, Python tests | Python | [PEP 8](https://peps.python.org/pep-0008/) plus Google-style docstrings for public APIs | +| Go components, Kubernetes controller, Go SDK | Go | [Effective Go](https://go.dev/doc/effective_go) and [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) | +| JavaScript/TypeScript SDKs | JavaScript/TypeScript | [TypeScript ESLint recommended and stylistic rule sets](https://typescript-eslint.io/users/configs/) | +| Kotlin SDKs | Kotlin | [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) | +| C# SDKs | C# | [Microsoft C# coding conventions](https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) | + +### Automated Enforcement + +| Area | Tooling and CI entry point | +| --- | --- | +| Server | `server/pyproject.toml`; `.github/workflows/server-test.yml` runs `uv run ruff check` | +| CLI and Python SDKs | package `pyproject.toml` files; `.github/workflows/sdk-tests.yml` runs `uv run ruff check` and `uv run pyright` | +| Go components and Kubernetes | `gofmt`, `go vet`, and `golangci-lint` where configured; component workflows run format, lint, build, and test checks | +| Go SDK | `.github/workflows/sdk-tests.yml` runs `gofmt`, `go vet`, and tests | +| JavaScript/TypeScript SDKs | `sdks/eslint.base.mjs` and package `eslint.config.mjs`; `.github/workflows/sdk-tests.yml` runs `pnpm run lint` and `pnpm run typecheck` | +| Kotlin SDKs | Gradle Spotless with ktlint; `.github/workflows/sdk-tests.yml` runs `./gradlew spotlessCheck ...` | +| C# SDKs | `.editorconfig`, `Directory.Build.props`, and .NET analyzers; `.github/workflows/sdk-tests.yml` runs `dotnet build ... /warnaserror` | + +### Python (Server, CLI, Python SDKs) + +- **Style Guide**: Follow [PEP 8](https://peps.python.org/pep-0008/) +- **Linter/Formatter**: Use `ruff` for linting and formatting +- **Type Hints**: Always use type hints for function signatures +- **Docstrings**: Use Google-style docstrings for public APIs + +```python +def create_sandbox( + image: ImageSpec, + timeout: timedelta, + entrypoint: Optional[List[str]] = None +) -> Sandbox: + """Create a new sandbox instance. + + Args: + image: Container image specification + timeout: Sandbox timeout duration + entrypoint: Optional custom entrypoint command + + Returns: + Created sandbox instance + + Raises: + ValueError: If image or timeout is invalid + """ + # Implementation +``` + +**Running Linter:** + +```bash +cd server +uv run ruff check +uv run ruff format opensandbox_server tests +``` + +### Go (components, Kubernetes, Go SDK) + +- **Style Guide**: Follow [Effective Go](https://go.dev/doc/effective_go) +- **Formatter**: Use `gofmt` for formatting +- **Imports**: Organize in three groups (stdlib, third-party, internal) +- **Error Handling**: Always handle errors explicitly + +```go +// Good +result, err := someOperation() +if err != nil { + logs.Error("operation failed: %v", err) + return fmt.Errorf("failed to do something: %w", err) +} + +// Bad - silent failure +result, _ := someOperation() +``` + +**Running Formatter:** + +```bash +cd components/execd +gofmt -w . +# Or +make fmt +``` + +### JavaScript/TypeScript (SDKs) + +- **Style Guide**: Follow the TypeScript ESLint recommended and stylistic rule sets configured in `sdks/eslint.base.mjs` +- **Linter**: Use `eslint` for JavaScript/TypeScript linting +- **Type Checking**: Run `tsc` with the package `tsconfig.json` + +**Running Checks:** + +```bash +cd sdks +pnpm run lint:js +pnpm run typecheck:js +``` + +### Java/Kotlin (Java/Kotlin SDKs) + +- **Style Guide**: Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) +- **Formatter**: Use `ktlint` +- **Null Safety**: Use Kotlin's null safety features + +```kotlin +suspend fun createSandbox( + image: ImageSpec, + timeout: Duration, + entrypoint: List? = null +): Sandbox { + // Implementation +} +``` + +### C# (C# SDKs) + +- **Style Guide**: Follow [Microsoft C# coding conventions](https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions) +- **Formatting**: Follow the SDK `.editorconfig` files +- **Analyzers**: Keep .NET analyzers enabled and treat build warnings as errors in CI + +**Running Checks:** + +```bash +cd sdks/sandbox/csharp +dotnet build OpenSandbox.sln --configuration Release /warnaserror + +cd ../../code-interpreter/csharp +dotnet build OpenSandbox.CodeInterpreter.sln --configuration Release /warnaserror +``` + +### General Guidelines + +- **Naming Conventions**: + - Functions/Methods: `snake_case` (Python), `camelCase` (Go, Kotlin) + - Classes: `PascalCase` (all languages) + - Constants: `UPPER_SNAKE_CASE` (all languages) + - Private members: `_leading_underscore` (Python), `unexported` (Go) + +- **Comments**: Write clear, concise comments explaining "why", not "what" +- **Error Messages**: Provide actionable error messages with context +- **Logging**: Use appropriate log levels (DEBUG, INFO, WARNING, ERROR) + +## Build System Standards + +OpenSandbox produces native Go binaries for `components/execd`, `components/ingress`, +`components/egress`, `kubernetes`, and `sdks/sandbox/go`. These build systems must +preserve caller-provided build flags and only append project-required flags. + +### Build Variables + +- Go builds pass caller-provided `GOFLAGS` to `go build` and append project flags such as `-trimpath` and `-buildvcs=false`. +- Go linker builds pass caller-provided `LDFLAGS` to `go build -ldflags` and append project metadata flags. +- When CGO is enabled, the Go toolchain honors `CC`, `CXX`, `CGO_CFLAGS`, `CGO_CXXFLAGS`, and `CGO_LDFLAGS`. Docker-based builds also accept `CFLAGS` and `CXXFLAGS` as fallbacks for `CGO_CFLAGS` and `CGO_CXXFLAGS`. +- Docker build scripts forward these variables as build arguments when they are present in the environment. + +### Debug Information + +Default project builds must not strip debug information. Do not add `strip`, +`install -s`, or Go linker flags such as `-s -w` to the default build path. +Distribution-specific packaging may strip binaries only outside the default +developer and CI build path. + +### Build Dependency Graph + +Use package-aware build tools instead of recursive independent builds: + +- Go packages are built through `go build ./...` or explicit Go package entry points. +- Kotlin projects use Gradle task dependencies. +- JavaScript and TypeScript SDKs use pnpm workspace dependencies. +- C# SDKs use solution/project references through `dotnet build`. + +Subdirectory-specific Make targets may delegate to these tools, but they must not +replace the build tool's dependency graph with independent recursive directory +builds where cross-directory dependencies exist. + +### Repeatable Builds + +Native Go binary builds include `-trimpath`, `-buildvcs=false`, and `-ldflags` +with `-buildid= -B none` so that source paths, VCS metadata, and build IDs or +Mach-O UUIDs do not make binaries differ. For repeatable release metadata, set `SOURCE_DATE_EPOCH` +or set `BUILD_TIME`, `VERSION`, and `GIT_COMMIT` explicitly before invoking +Makefile or Docker build scripts. When `SOURCE_DATE_EPOCH` is set and +`BUILD_TIME` is unset, build scripts derive `BUILD_TIME` from +`SOURCE_DATE_EPOCH`. + +## Testing Guidelines + +### Test Coverage Requirements + +- **Core Packages**: Aim for >80% coverage +- **API Layer**: Aim for >70% coverage +- **Utilities**: Aim for >90% coverage + +### Writing Tests + +#### Python Tests (pytest) + +```python +import pytest +from opensandbox import Sandbox + +@pytest.mark.asyncio +async def test_create_sandbox(): + """Test sandbox creation with valid parameters.""" + sandbox = await Sandbox.create( + image="python:3.11", + timeout=timedelta(minutes=5) + ) + assert sandbox.id is not None + assert sandbox.status == SandboxStatus.PENDING + await sandbox.kill() + +@pytest.mark.asyncio +async def test_invalid_timeout(): + """Test sandbox creation fails with invalid timeout.""" + with pytest.raises(ValueError): + await Sandbox.create( + image="python:3.11", + timeout=timedelta(seconds=-1) + ) +``` + +**Running Tests:** + +```bash +cd server +uv run pytest +uv run pytest --cov=src --cov-report=html +``` + +#### Go Tests + +```go +func TestController_Execute_Python(t *testing.T) { + ctrl := NewController("http://localhost:8888", "test-token") + + req := &ExecuteCodeRequest{ + Language: Python, + Code: "print('hello')", + } + + err := ctrl.Execute(req) + assert.NoError(t, err) +} +``` + +**Running Tests:** + +```bash +cd components/execd +go test ./pkg/... +go test -v -cover ./pkg/... +``` + +#### Integration Tests + +Integration tests require Docker: + +```bash +# Server integration tests +cd server +uv run pytest tests/integration/ + +# E2E tests +cd tests/e2e/python +uv run pytest +``` + +### Test Best Practices + +- **Test Names**: Use descriptive names that explain what is being tested +- **Arrange-Act-Assert**: Structure tests clearly +- **Isolation**: Each test should be independent +- **Mocking**: Mock external dependencies appropriately +- **Cleanup**: Always clean up resources (use fixtures, context managers) + +## Submitting Contributions + +### Pull Request Process + +1. **Create Feature Branch**: Branch from `main` +2. **Make Changes**: Implement your feature or fix +3. **Write Tests**: Add comprehensive test coverage +4. **Update Documentation**: Update relevant docs +5. **Test Locally**: Ensure all tests pass +6. **Run Linters**: Fix any style issues +7. **Commit Changes**: Use conventional commit messages +8. **Push to Fork**: Push your branch to your fork +9. **Create Pull Request**: Submit PR with detailed description + +### Pull Request Template + +When creating a PR, fill out the template: + +```markdown +# Summary + +- What is changing and why? + +# Testing + +- [ ] Not run (explain why) +- [ ] Unit tests +- [ ] Integration tests +- [ ] e2e / manual verification + +# Breaking Changes + +- [ ] None +- [ ] Yes (describe impact and migration path) + +# Checklist + +- [ ] Linked Issue or clearly described motivation +- [ ] Added/updated docs (if needed) +- [ ] Added/updated tests (if needed) +- [ ] Security impact considered +- [ ] Backward compatibility considered +``` + +### Pull Request Guidelines + +**Do:** + +- Keep PRs focused and reasonably sized (< 500 lines if possible) +- Write clear PR descriptions with motivation and context +- Link related issues +- Respond to review comments promptly +- Update your PR based on feedback +- Ensure CI passes before requesting review + +**Don't:** + +- Mix multiple unrelated changes in one PR +- Submit PRs with failing tests +- Ignore code review feedback +- Force push after reviews have started (unless necessary) +- Include commented-out code or debug statements + +### Code Review Process + +1. **Automated Checks**: CI runs tests, linters, and security scans +2. **Maintainer Review**: A maintainer reviews your code +3. **Feedback Loop**: Address review comments +4. **Approval**: Once approved, a maintainer will merge your PR +5. **Cleanup**: Delete your feature branch after merge + +## Communication Channels + +### GitHub Issues + +Use GitHub Issues for: + +- Bug reports +- Feature requests +- Documentation improvements +- Questions about implementation + +**Bug Report Template:** + +```markdown +**Description** +A clear description of the bug. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Create sandbox with... +2. Execute command... +3. See error + +**Expected Behavior** +What you expected to happen. + +**Environment** + +- OpenSandbox version: +- Runtime (Docker/K8s): +- OS: +- Python/Go version: + +**Additional Context** +Logs, screenshots, or other relevant information. +``` + +### GitHub Discussions + +Use GitHub Discussions for: + +- General questions +- Design discussions +- Brainstorming ideas +- Community help + +### Getting Help + +- **Issues**: Technical problems or bugs +- **Discussions**: Questions and community support +- **Security vulnerabilities**: Follow [SECURITY.md](SECURITY.md) and use + GitHub private vulnerability reporting. Do not open a public issue. +- **Code of Conduct reports**: Email conduct@opensandbox.io + +## Additional Resources + +### Documentation + +- [Architecture Overview](docs/architecture/) +- [Server Development Guide](server/DEVELOPMENT.md) +- [execd Development Guide](components/execd/DEVELOPMENT.md) +- [OpenAPI Specifications](specs/README.md) +- [Python SDK Documentation](sdks/sandbox/python/README.md) +- [Java/Kotlin SDK Documentation](sdks/sandbox/kotlin/README.md) + +### Examples + +Browse [examples/](examples/) for real-world usage patterns: + +- Code Interpreter integration +- AI Coding Agent integrations (Claude Code, Gemini CLI, etc.) +- Browser automation (Chrome, Playwright) +- Remote development (VS Code, Desktop) + +### External Resources + +- [FastAPI Documentation](https://fastapi.tiangolo.com/) +- [Beego Documentation](https://beego.wiki/) +- [Jupyter Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html) +- [OpenAPI Specification](https://swagger.io/specification/) +- [Docker API](https://docs.docker.com/engine/api/) + +## Acknowledgments + +Thank you for contributing to OpenSandbox! Your contributions help make this project better for everyone in the AI and developer tools community. + +If you have suggestions for improving this contributing guide, please open an issue or submit a pull request. + +## License + +By contributing to OpenSandbox, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE). diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..31cbd7e --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,286 @@ +# OpenSandbox Governance + +This document describes how OpenSandbox is governed today and how technical +decisions are made in the project. + +It is intended to reflect the project's current open development practice. As +OpenSandbox grows, this document may evolve to support a more formal governance +model. + +## Goals + +OpenSandbox governance is designed to keep the project: + +- open to contributors and users +- pragmatic in day-to-day decision making +- transparent in technical direction +- safe for public API, runtime, and security-sensitive changes +- sustainable across multiple components and maintainers + +## Scope + +This document applies to the OpenSandbox repository and its major public +surfaces, including: + +- public APIs and specifications in `specs/` +- the lifecycle server in `server/` +- runtime components in `components/` +- Kubernetes controller and related assets in `kubernetes/` +- SDKs in `sdks/` +- CLI, examples, and project documentation + +## Project Values + +OpenSandbox maintainers and contributors are expected to act consistently with +these principles: + +- **Open development**: design discussion, issue tracking, and code review + happen in public whenever possible. +- **Compatibility awareness**: public APIs, SDKs, CLI behavior, and documented + user workflows should not change casually. +- **Security first**: changes affecting isolation, networking, credentials, or + execution safety require extra scrutiny. +- **Component ownership with cross-project accountability**: subsystem + maintainers own their areas, while cross-cutting changes require broader + review. +- **Documentation and implementation alignment**: public contracts, code, SDKs, + examples, and docs should stay consistent. + +## Roles + +### Contributors + +Contributors are anyone who participates in the project, including by: + +- opening issues or discussions +- submitting pull requests +- reviewing code +- improving docs, tests, examples, or tooling + +Contributors are expected to follow: + +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +- [CONTRIBUTING.md](CONTRIBUTING.md) +- [SECURITY.md](SECURITY.md) for vulnerability reporting + +### Maintainers + +Maintainers are contributors entrusted with reviewing and guiding changes in one +or more parts of the repository. + +Today, the public record of subsystem maintainership is the +[`CODEOWNERS`](.github/CODEOWNERS) file. Code owners for a given path are the +default maintainers for that area. + +Maintainer responsibilities include: + +- reviewing pull requests for owned areas +- helping preserve code quality, compatibility, and security +- requesting cross-component review when a change affects other surfaces +- keeping specs, implementation, tests, docs, and examples aligned when needed +- helping contributors land changes successfully + +### Project Maintainers + +Project Maintainers are the maintainers responsible for repository-wide +direction and final technical decisions when a matter cannot be resolved within +normal component review. + +Until a dedicated `MAINTAINERS.md` is added, the fallback `*` owners in +[`CODEOWNERS`](.github/CODEOWNERS) are treated as the public list of current +Project Maintainers. + +Project Maintainers are responsible for: + +- cross-cutting technical decisions +- governance updates +- maintainer onboarding and offboarding +- resolving review deadlocks +- ensuring major changes follow the appropriate design process + +## Decision Making + +### Day-to-Day Changes + +Most changes are made through the normal pull request workflow described in +[CONTRIBUTING.md](CONTRIBUTING.md): + +1. discuss the change in an issue when appropriate +2. submit a pull request +3. pass automated checks +4. receive maintainer review for affected areas +5. address feedback +6. merge once approved + +Normal changes are decided by maintainer review and rough consensus. + +### Component-Level Decisions + +For changes limited to one subsystem, the maintainers of that subsystem are the +primary decision makers. Their review should carry the most weight for: + +- implementation details +- code structure +- tests +- operational behavior within that component + +If a change affects multiple subsystems, the relevant maintainers should be +involved before merge. + +### Cross-Cutting and Sensitive Decisions + +Changes with broader impact require wider review. This includes changes to: + +- public APIs or schemas in `specs/` +- SDK interfaces or generated outputs +- CLI behavior +- lifecycle semantics +- runtime isolation or security guarantees +- ingress, egress, or authentication behavior +- release processes or repository-wide tooling + +For these changes, maintainers should seek explicit review from all materially +affected areas, not just the first area touched by the patch. + +### Major Design Changes and OSEPs + +OpenSandbox uses the OpenSandbox Enhancement Proposal process (`OSEP`) for major +changes. + +An OSEP is expected for changes that: + +- introduce major features or architectural changes +- modify the core API or runtime behavior +- affect the security model or isolation guarantees + +The public OSEP process is documented in: + +- [oseps/README.md](oseps/README.md) +- [oseps/CONTRIBUTING.md](oseps/CONTRIBUTING.md) + +When an OSEP is required, implementation should follow the approved design +direction rather than bypassing it through code review alone. + +### Consensus and Voting + +OpenSandbox prefers **lazy consensus** for most technical decisions: + +- if affected maintainers agree, the change may proceed +- if concerns are raised, they should be addressed in the PR, issue, or OSEP + discussion + +If consensus cannot be reached in a reasonable time, Project Maintainers may use +a simple majority vote of participating Project Maintainers. + +Voting rules: + +- each participating Project Maintainer has one vote +- maintainers should recuse themselves in case of direct conflicts of interest +- if a vote ties, the proposal does not pass and the status quo remains + +## Reviews and Merge Expectations + +The following expectations apply before merge: + +- relevant CI checks should pass, or failures must be understood and accepted by + maintainers +- at least one maintainer of the affected area should review the change +- cross-cutting changes should be reviewed by all materially affected areas when + practical +- breaking changes should be clearly called out, with migration guidance where + needed +- docs and tests should be updated when behavior changes + +Maintainers may decline or defer a change if it: + +- conflicts with approved design direction +- introduces unnecessary compatibility risk +- weakens security or isolation without a strong justification +- mixes unrelated work into a single change + +## Public Interfaces and Compatibility + +OpenSandbox treats several surfaces as public contracts: + +- API specifications in `specs/` +- published SDKs +- CLI behavior +- documented configuration and deployment behavior + +Maintainers should prefer additive and backward-compatible changes when +possible. + +When changing a public contract, maintainers should update or verify the +affected implementation, SDKs, docs, examples, and release outputs in the same +change when practical. + +## Releases + +Releases are managed through the public workflows and scripts in this +repository, including the GitHub Actions workflows under `.github/workflows/` +and the release tooling documented in `docs/community/release-automation.md`. + +Maintainers responsible for a release target are expected to ensure that: + +- the target has appropriate validation +- release notes accurately reflect user-visible changes +- versioning and tags follow the documented release conventions + +## Communication Channels + +The project's public collaboration channels are: + +- GitHub Issues for bugs, feature requests, and implementation questions +- GitHub Discussions for broader design discussion and community help +- pull requests for concrete code and documentation review +- OSEPs for major design work + +Security issues should follow the private reporting guidance in +[SECURITY.md](SECURITY.md). + +## Becoming a Maintainer + +New maintainers are selected based on sustained, high-quality contribution to +the project. + +Signals that someone may be ready for maintainership include: + +- repeated high-quality code or documentation contributions +- strong reviews and constructive technical feedback +- reliable follow-through on owned work +- good judgment on compatibility, security, and project direction +- collaborative behavior with contributors and maintainers + +The typical process is: + +1. nomination by an existing Project Maintainer +2. discussion among Project Maintainers +3. no unresolved objections after a reasonable review period, or approval by + majority vote if needed +4. update of `CODEOWNERS` and any other relevant public maintainer records + +## Maintainer Inactivity and Removal + +Maintainers may step down at any time by notifying the project. + +Project Maintainers may also update maintainer status when someone has been +inactive for an extended period, for example several months without meaningful +review or maintenance activity. + +Removal should be handled respectfully and pragmatically, with the goal of +keeping ownership accurate rather than punitive. + +Maintainers may also be removed for serious violations of project expectations, +including repeated abuse of project privileges or violations of the Code of +Conduct. + +## Governance Changes + +Changes to this document should be made through a public pull request. + +Governance changes should receive review from Project Maintainers and should not +be merged without giving maintainers and contributors a reasonable opportunity +to comment. + +Substantial governance changes may be proposed through an OSEP or a dedicated +governance discussion if maintainers believe broader review is warranted. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b09cd78 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bab782c --- /dev/null +++ b/README.md @@ -0,0 +1,300 @@ +
+ OpenSandbox logo + +

OpenSandbox

+ +

+ opensandbox-group%2FOpenSandbox | Trendshift +

+ +

+ Stars + OpenSSF Best Practices + CNCF Landscape + Discord + DingTalk + E2E Status + Kubernetes nightly build status +

+ +
+
+ +OpenSandbox is a **general-purpose sandbox platform** for AI applications, offering multi-language SDKs, unified sandbox APIs, and Docker/Kubernetes runtimes for scenarios like Coding Agents, GUI Agents, Agent Evaluation, AI Code Execution, and RL Training. + +## Features + +- 🧩 **SDKs, CLI, and MCP**: Provides multi-language SDKs, the osb CLI, and MCP server integration for sandbox creation, command execution, and file operations. See [SDKs](#sdks), [CLI](#cli), and [MCP](#mcp). +- 📜 **Sandbox Protocol**: Defines sandbox lifecycle management APIs and sandbox execution APIs so you can extend custom sandbox runtimes. See [API specs](specs/README.md). +- 🚀 **Sandbox Runtime**: Built-in lifecycle management supporting Docker and high-performance Kubernetes runtime, enabling both local runs and large-scale distributed scheduling. See [Kubernetes runtime](./kubernetes). +- 🖥️ **Sandbox Environments**: Built-in Command, Filesystem, and Code Interpreter implementations. Examples cover Coding Agents (e.g., Claude Code), browser automation (Chrome, Playwright), and desktop environments (VNC, VS Code). +- 🚦 **Network Policy**: Unified ingress gateway with multiple routing strategies plus per-sandbox egress controls. See [Ingress Gateway](components/ingress) and [egress controls](components/egress). +- 🔑 **Credential Vault**: Secure credential injection for sandbox outbound requests without exposing real secrets to workloads. See [Credential Vault](docs/guides/credential-vault.md). +- 🏰 **Strong Isolation**: Supports secure container runtimes like gVisor, Kata Containers, and Firecracker microVM for enhanced isolation between sandbox workloads and the host. See [Secure Container Runtime Guide](docs/guides/secure-container.md) for details. + +## SDKs + +Python: + +```bash +pip install opensandbox +``` + +Java/Kotlin (Gradle Kotlin DSL): + +```kotlin +dependencies { + implementation("com.alibaba.opensandbox:sandbox:{latest_version}") +} +``` + +Java/Kotlin (Maven): + +```xml + + com.alibaba.opensandbox + sandbox + {latest_version} + +``` + +JavaScript/TypeScript: + +```bash +npm install @alibaba-group/opensandbox +``` + +C#/.NET: + +```bash +dotnet add package Alibaba.OpenSandbox +``` + +Go: + +```bash +go get github.com/alibaba/OpenSandbox/sdks/sandbox/go +``` + +## CLI + +OpenSandbox also provides `osb`, a terminal CLI for the common sandbox workflow: create sandboxes, run commands, move files, inspect diagnostics, and manage runtime egress policy. + +Install: + +```bash +pip install opensandbox-cli +# or +uv tool install opensandbox-cli +``` + +Quick start: + +```bash +osb config init +osb config set connection.domain localhost:8080 +osb config set connection.protocol http +osb config set connection.api_key +osb sandbox create --image python:3.12 --timeout 30m -o json +osb command run -o raw -- python -c "print(1 + 1)" +``` + +See the [CLI README](cli/README.md) for the full command reference. + +## MCP + +The OpenSandbox MCP server exposes sandbox creation, command execution, and text file operations to MCP-capable clients such as Claude Code and Cursor. + +Install and run: + +```bash +pip install opensandbox-mcp +opensandbox-mcp --domain localhost:8080 --protocol http +``` + +Minimal stdio config: + +```json +{ + "mcpServers": { + "opensandbox": { + "command": "opensandbox-mcp", + "args": ["--domain", "localhost:8080", "--protocol", "http"] + } + } +} +``` + +See the [MCP README](sdks/mcp/sandbox/python/README.md) for client-specific setup. + +## Getting Started + +Requirements: + +- Docker (required for local execution) +- Python 3.10+ (required for examples and local runtime) + +### Install and Configure the Sandbox Server + +```bash +uvx opensandbox-server init-config ~/.sandbox.toml --example docker + +uvx opensandbox-server + +# Show help +# uvx opensandbox-server -h +``` + +### Create a Code Interpreter and Execute Commands/Codes + +Install the Code Interpreter SDK + +```bash +uv pip install opensandbox-code-interpreter +``` + +Create a sandbox and execute commands and codes. + +```python +import asyncio +from datetime import timedelta + +from code_interpreter import CodeInterpreter, SupportedLanguage +from opensandbox import Sandbox +from opensandbox.models import WriteEntry + +async def main() -> None: + # 1. Create a sandbox + sandbox = await Sandbox.create( + "opensandbox/code-interpreter:v1.1.0", + entrypoint=["/opt/code-interpreter/code-interpreter.sh"], + env={"PYTHON_VERSION": "3.11"}, + timeout=timedelta(minutes=10), + ) + + async with sandbox: + + # 2. Execute a shell command + execution = await sandbox.commands.run("echo 'Hello OpenSandbox!'") + print(execution.logs.stdout[0].text) + + # 3. Write a file + await sandbox.files.write_files([ + WriteEntry(path="/tmp/hello.txt", data="Hello World", mode=644) + ]) + + # 4. Read a file + content = await sandbox.files.read_file("/tmp/hello.txt") + print(f"Content: {content}") # Content: Hello World + + # 5. Create a code interpreter + interpreter = await CodeInterpreter.create(sandbox) + + # 6. Execute Python code (single-run, pass language directly) + result = await interpreter.codes.run( + """ + import sys + print(sys.version) + result = 2 + 2 + result + """, + language=SupportedLanguage.PYTHON, + ) + + print(result.result[0].text) # 4 + print(result.logs.stdout[0].text) # 3.11.14 + + # 7. Cleanup the sandbox + await sandbox.kill() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### More Examples + +OpenSandbox provides examples covering SDK usage, agent integrations, browser automation, and training workloads. All example code is located in the `examples/` directory. + +#### 🎯 Basic Examples + +- **[code-interpreter](docs/examples/code-interpreter.md)** - End-to-end Code Interpreter SDK workflow in a sandbox. +- **[aio-sandbox](docs/examples/aio-sandbox.md)** - All-in-One sandbox setup using the OpenSandbox SDK. +- **[agent-sandbox](docs/examples/agent-sandbox.md)** - Example integration for running OpenSandbox workloads on Kubernetes with [kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox). +- **Volumes** — [Docker PVC / named volumes](docs/examples/docker-pvc-volume-mount.md), [Docker OSSFS](docs/examples/docker-ossfs-volume-mount.md), [Kubernetes PVC](docs/examples/kubernetes-pvc-volume-mount.md): persistent and shared storage patterns. + +#### 🤖 Coding Agent Integrations + +- **Coding CLIs** — [Claude Code](docs/examples/claude-code.md), [Gemini CLI](docs/examples/gemini-cli.md), [OpenAI Codex CLI](docs/examples/codex-cli.md), [Qwen Code](docs/examples/qwen-code.md), [Kimi CLI](docs/examples/kimi-cli.md): run each vendor CLI inside OpenSandbox. +- **[langgraph](docs/examples/langgraph.md)** - LangGraph state-machine workflow that creates/runs a sandbox job with fallback retry. +- **[google-adk](docs/examples/google-adk.md)** - Google ADK agent using OpenSandbox tools to write/read files and run commands. +- **[openclaw](docs/examples/openclaw.md)** - Launch an OpenClaw Gateway inside a sandbox. + +#### 🌐 Browser and Desktop Environments + +- **[chrome](docs/examples/chrome.md)** - Chromium sandbox with VNC and DevTools access for automation and debugging. +- **[playwright](docs/examples/playwright.md)** - Playwright + Chromium headless scraping and testing example. +- **[desktop](docs/examples/desktop.md)** - Full desktop environment in a sandbox with VNC access. +- **[vscode](docs/examples/vscode.md)** - code-server (VS Code Web) running inside a sandbox for remote dev. + +#### 🧠 Training and Evaluation + +- **[rl-training](docs/examples/rl-training.md)** - DQN CartPole training in a sandbox with checkpoints and summary output. +- **[harbor-evaluation](docs/examples/harbor-evaluation.md)** - Run a [Harbor](https://github.com/harbor-framework/harbor) agent evaluation on OpenSandbox, one sandbox per trial. + +For more details, please refer to the [examples documentation](docs/examples/index.md). + +## Project Structure + +| Directory | Description | +|-----------|------------------------------------------------------------------| +| [`sdks/`](sdks/) | Multi-language SDKs (Python, Java/Kotlin, TypeScript/JavaScript, C#/.NET) | +| [`specs/`](specs/README.md) | OpenAPI specs and lifecycle specifications | +| [`server/`](server/README.md) | Python FastAPI sandbox lifecycle server | +| [`cli/`](cli/README.md) | OpenSandbox command-line interface | +| [`kubernetes/`](kubernetes/README.md) | Kubernetes deployment and examples | +| [`components/execd/`](components/execd/README.md) | Sandbox execution daemon (commands and file operations) | +| [`components/ingress/`](components/ingress/README.md) | Sandbox traffic ingress proxy | +| [`components/egress/`](components/egress/README.md) | Sandbox network egress control | +| [`sandboxes/`](sandboxes/) | Runtime sandbox implementations | +| [`examples/`](examples/) | Runnable example code | +| [`docs/examples/`](docs/examples/index.md) | Example documentation and use cases | +| [`oseps/`](oseps/README.md) | OpenSandbox Enhancement Proposals | +| [`docs/`](docs/) | Architecture and design documentation | +| [`tests/`](tests/) | Cross-component E2E tests | +| [`scripts/`](scripts/) | Development and maintenance scripts | + +For detailed architecture, see [Architecture](docs/architecture/). + +## Documentation + +- [Architecture](docs/architecture/) – Overall architecture & design philosophy +- [Credential Vault](docs/guides/credential-vault.md) - Credential Vault credential injection guide +- [Release Verification](docs/community/release-verification.md) - Release signing and artifact verification +- [oseps/README.md](oseps/README.md) – OpenSandbox Enhancement Proposals +- SDK + - Sandbox base SDK ([Java/Kotlin SDK](sdks/sandbox/kotlin/README.md), [Python SDK](sdks/sandbox/python/README.md), [JavaScript/TypeScript SDK](sdks/sandbox/javascript/README.md), [C#/.NET SDK](sdks/sandbox/csharp/README.md)), [Go SDK](sdks/sandbox/go/README.md) - includes sandbox lifecycle, command execution, file operations + - Code Interpreter SDK ([Java/Kotlin SDK](sdks/code-interpreter/kotlin/README.md), [Python SDK](sdks/code-interpreter/python/README.md), [JavaScript/TypeScript SDK](sdks/code-interpreter/javascript/README.md), [C#/.NET SDK](sdks/code-interpreter/csharp/README.md)) - code interpreter +- [cli/README.md](cli/README.md) - OpenSandbox CLI installation and command reference +- [sdks/mcp/sandbox/python/README.md](sdks/mcp/sandbox/python/README.md) - MCP server installation and client setup +- [specs/README.md](specs/README.md) - OpenAPI definitions for sandbox lifecycle API and sandbox execution API +- [server/README.md](server/README.md) - Sandbox server startup and configuration; supports Docker and Kubernetes runtimes +- [ROADMAP.md](ROADMAP.md) - Lightweight project roadmap and planning process + +## License + +This project is open source under the [Apache 2.0 License](LICENSE). + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for the current project roadmap, planning scope, +and how roadmap items are managed. + +## Contact and Discussion + +- Issues: Submit bugs, feature requests, or design discussions through GitHub Issues +- Discord: Join the [OpenSandbox Discord community](https://discord.gg/g7FuPs8YeD) +- DingTalk: Join the [OpenSandbox technical discussion group](https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11) + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=opensandbox-group/OpenSandbox&type=date&legend=top-left)](https://www.star-history.com/#opensandbox-group/OpenSandbox&type=date&legend=top-left) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..1f7bb25 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`opensandbox-group/OpenSandbox` +- 原始仓库:https://github.com/opensandbox-group/OpenSandbox +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..61dcf88 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,81 @@ +# OpenSandbox Roadmap + +Last updated: 2026-04-28 + +This roadmap describes the intended project direction for roughly the next 12 +months. It is a planning guide, not a release commitment. Implementation details +are tracked through GitHub Issues, pull requests, and OpenSandbox Enhancement +Proposals (OSEPs). + +## Principles + +- Keep public API contracts, SDKs, CLI behavior, implementation, examples, and + documentation aligned. +- Prefer additive, backward-compatible changes for public interfaces. +- Use the OSEP process for major architecture, API, runtime, or security-model + changes. +- Keep roadmap items focused on project direction instead of using this file as + a full backlog. +- Move completed work into release notes, OSEP status, or stable documentation + instead of keeping long historical task lists here. + +## Current Focus: 2026 H1-H2 + +### Sandbox Runtime + +| Area | Status | Tracking | Notes | +|------|--------|----------|-------| +| Local lightweight sandbox | Planned | TBD | Lightweight sandbox runtime for AI tools running directly on PCs. | +| Persistent volumes | Implementing | [OSEP-0003](oseps/0003-volume-and-volumebinding-support.md) | Close remaining runtime/backend gaps from OSEP-0003 before treating volume support as mature. | +| Secure container runtime | Maturing | [OSEP-0004](oseps/0004-secure-container-runtime.md), [secure container guide](docs/guides/secure-container.md) | Continue hardening isolation guidance and deployment practices. | +| Pause and resume via rootfs snapshot | Implementing | [OSEP-0008](oseps/0008-pause-resume-rootfs-snapshot.md) | Improve lifecycle support for stateful sandbox workflows. | +| Secure endpoint access | Implemented / maturing | [OSEP-0011](oseps/0011-secure-access-endpoint.md) | Keep endpoint security behavior aligned across server, SDKs, and docs. | + +### SDKs and Developer Experience + +| Area | Status | Tracking | Notes | +|------|--------|----------|-------| +| SDK parity | Ongoing | [sdks/](sdks/), [specs/](specs/README.md) | Keep Python, Go, Kotlin, JavaScript/TypeScript, and C# SDKs aligned with public specs. | +| Client-side sandbox pool | Implementing / maturing | [OSEP-0005](oseps/0005-client-side-sandbox-pool.md) | Expand behavior consistency, tests, and documentation where practical. | +| CLI usability | Planned | [cli/](cli/README.md) | Improve common sandbox lifecycle workflows and developer ergonomics. | +| Developer console | Implementable | [OSEP-0006](oseps/0006-developer-console.md) | Provide a clearer operational surface for sandbox users and maintainers. | + +### Observability and Operations + +| Area | Status | Tracking | Notes | +|------|--------|----------|-------| +| OpenTelemetry metrics and logs | Implementing | [OSEP-0010](oseps/0010-opentelemetry-instrumentation.md) | Add observability across execd, ingress, and egress. | +| Agent in-sandbox audit trail | Planned | TBD / OSEP needed | Define auditable records for agent actions inside sandboxes, such as command/session execution, file operations, network access, identity context, retention, and privacy boundaries. | +| Kubernetes deployment | Ongoing | [kubernetes/](kubernetes/README.md), [Helm charts](kubernetes/charts/) | Keep self-hosted deployment, chart, and operational documentation current. | +| Network isolation guidance | Ongoing | [network isolation guide](docs/architecture/network-isolation.md) | Continue documenting safe defaults and practical isolation patterns. | + +### Public Contracts and Governance + +| Area | Status | Tracking | Notes | +|------|--------|----------|-------| +| Lifecycle API stability | Ongoing | [specs/](specs/README.md), [OSEPs](oseps/README.md) | Preserve compatibility and require clear migration paths for user-visible changes. | +| Security documentation | Ongoing | [SECURITY.md](SECURITY.md), [docs/](docs/) | Keep vulnerability reporting, security expectations, and deployment guidance current. | +| Open project governance | Ongoing | [GOVERNANCE.md](GOVERNANCE.md), [CONTRIBUTING.md](CONTRIBUTING.md) | Maintain a lightweight, public decision process as the project grows. | + +## Not Currently Planned + +- Declaring a stable v1 API before lifecycle semantics, runtime behavior, and + SDK compatibility are mature enough to support it. +- Breaking public specs, SDK interfaces, CLI behavior, or documented workflows + without an OSEP and migration path. +- Provider-specific features that cannot be documented, tested, or isolated + cleanly from the public API surface. +- Heavy release-train governance before the project has enough maintainer + capacity to keep that process current. + +## How Roadmap Items Are Managed + +- Small work is tracked as GitHub Issues and pull requests. +- Major features, architecture changes, public API changes, runtime behavior + changes, and security-model changes start with an OSEP. +- Active roadmap entries should link to an issue, PR, OSEP, or documentation + page once a stable tracking location exists. +- Completed work should be reflected in release notes, implemented OSEPs, and + user documentation rather than remaining as an active roadmap item. +- Maintainers should review this file at least quarterly, or whenever a major + OSEP changes status. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f4e5c60 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Reporting Security Issues + +The OpenSandbox team takes security seriously. If you discover a security vulnerability, please report it responsibly. + +### How to Report + +- **GitHub Private Vulnerability Reporting**: Open a + [private security advisory](https://github.com/opensandbox-group/OpenSandbox/security/advisories/new) + +Do not report vulnerabilities in a public issue or to the Code of Conduct +reporting address. The `conduct@opensandbox.io` address is only for community +conduct reports. + +### What to Include + +- Clear description of the vulnerability +- Steps to reproduce +- Potential impact and scope +- Suggested remediation (if available) + +## Response Process + +1. Acknowledgment within 48 hours +2. Investigation and validation +3. Fix development and testing +4. Coordinated disclosure + +## Supported Versions + +Only the latest release and main branch are actively supported with security updates. + +## Release Signatures + +OpenSandbox signs public release outputs with GitHub/Sigstore attestations, +cosign keyless container signatures, and Maven Central package signatures where +applicable. See [Release Verification](docs/community/release-verification.md) for the +trusted signer identities and verification commands. + +## Security Best Practices + +When deploying OpenSandbox: +- Keep dependencies up to date +- Use network policies to restrict sandbox egress +- Monitor audit logs regularly +- Follow principle of least privilege + +## Cryptographic Key Length Policy + +OpenSandbox TLS defaults are aligned with OpenSSF `crypto_keylength` guidance +(NIST minimum strength through year 2030, as stated in 2012): + +- symmetric key: at least 112 bits +- factoring modulus (RSA): at least 2048 bits +- discrete logarithm key: at least 224 bits +- discrete logarithm group: at least 2048 bits +- elliptic curve key: at least 224 bits +- hash algorithm strength: at least 224 bits + +Project-owned enforcement points include: + +- Go SDK default transport certificate validation +- Kubernetes controller validation for configured webhook/metrics TLS certificates + +For controlled interoperability scenarios, legacy weaker key lengths can be explicitly enabled: + +- Go SDK: set `TransportConfig.AllowWeakServerCertKeyLengths=true` +- Kubernetes controller: set `--allow-weak-tls-keylengths=true` diff --git a/cli/AGENTS.md b/cli/AGENTS.md new file mode 100644 index 0000000..26baf41 --- /dev/null +++ b/cli/AGENTS.md @@ -0,0 +1,115 @@ +# CLI AGENTS + +You are working on the OpenSandbox CLI. Keep commands as thin, predictable wrappers over the Python SDK, and keep help text, README examples, bundled skills, and tests aligned whenever user-visible CLI behavior changes. + +## Scope + +- `src/opensandbox_cli/**` +- `tests/**` +- `README.md` +- `pyproject.toml` and `uv.lock` when CLI dependencies or release metadata change +- `assets/**` only when screenshots or visual CLI documentation are intentionally refreshed + +If the task changes SDK-facing behavior, also read `../sdks/AGENTS.md`. If the task is driven by public API contracts, also read `../specs/AGENTS.md`. + +## Key Areas + +- `src/opensandbox_cli/main.py`: root command registration, global options, version/banner behavior +- `src/opensandbox_cli/client.py`: resolved config, SDK manager/client construction, output formatter wiring +- `src/opensandbox_cli/commands/`: command groups and command-scoped options +- `src/opensandbox_cli/output.py`: table, JSON, YAML, and raw rendering behavior +- `src/opensandbox_cli/skills/`: bundled skills installed into external agent tools +- `src/opensandbox_cli/skill_registry.py`: skill metadata shown by `osb skills list/show` +- `tests/test_cli_help.py`: root and command help coverage +- `tests/test_commands.py`: SDK-backed command behavior with mocked SDK calls +- `tests/test_skills.py`: bundled skill quality and CLI alignment checks + +## Command Design + +Prefer clear command groups whose names match stable product concepts. Commands should call Python SDK facades such as `SandboxManager`, `Sandbox`, or service objects instead of rebuilding HTTP paths locally. Raw HTTP or private client access is acceptable only for explicitly experimental or legacy commands. + +For new stable commands: + +- expose concise flags with names that match SDK/API concepts +- support `-o/--output` consistently with nearby commands +- use `raw` only for payload text or streaming-style output +- use `json` / `yaml` for structured descriptors or SDK models +- register the command in `main.py` +- add help tests and mocked SDK command tests +- update README examples when the command is user-facing +- update bundled skills when agents should use the command + +For deprecated commands: + +- preserve old option meanings, especially short flags +- do not silently reuse an old flag for a new concept +- print or return explicit migration guidance +- keep compatibility wrappers small and route to the stable implementation when practical + +## Skills + +Bundled skills are operational guidance for real agents, not long-form documentation. Keep them concise, command-first, and aligned with actual CLI behavior. + +When changing commands that appear in skills: + +- update the relevant skill examples +- explain only the semantics agents need to make decisions +- keep examples executable and include explicit `-o` output formats +- avoid relying on deprecated CLI APIs unless the skill clearly marks them as fallback +- update `skill_registry.py` summaries when the skill's advertised behavior changes +- update `tests/test_skills.py` so command examples and option names stay aligned with the CLI + +## Commands + +Common CLI checks: + +```bash +cd cli +uv run --frozen ruff check +uv run --frozen pyright +uv run --frozen pytest tests/ -q +``` + +Focused checks: + +```bash +cd cli +uv run --frozen pytest tests/test_cli_help.py -q +uv run --frozen pytest tests/test_commands.py -q +uv run --frozen pytest tests/test_skills.py -q +``` + +Use `--frozen` for validation when you do not intend to update `uv.lock`. If `uv run` changes `uv.lock` unexpectedly, inspect the diff and keep it only when the dependency graph intentionally changed. + +## Guardrails + +Always: + +- Keep CLI behavior aligned with the Python SDK surface it wraps. +- Keep help text accurate for supported options and output formats. +- Add or update tests for new commands, changed flags, changed rendering, and skill examples. +- Preserve command output compatibility unless the migration is explicit and documented. +- Treat bundled skills as part of the user-facing CLI surface. +- Keep command implementations small; put reusable rendering, validation, and error handling in local helpers when that matches existing style. +- Mention verification that was not run in the final handoff. + +Ask first: + +- Removing a command or flag. +- Changing the meaning of an existing option or short flag. +- Making a legacy or experimental command the preferred path without a migration story. +- Changing installed skill formats or target layouts. + +Never: + +- Reimplement stable SDK APIs with ad hoc HTTP calls in a new stable CLI command. +- Update generated or lock files as incidental noise. +- Leave README, help text, or bundled skills pointing at deprecated commands after adding a stable replacement. +- Mix unrelated CLI feature work into a command behavior change. + +## Good Patterns + +- Stable command wraps SDK manager/service method and shares rendering helpers. +- Legacy command delegates to the stable implementation and emits migration guidance. +- Tests mock `ClientContext` and assert exact SDK calls. +- Skill tests assert that examples use existing commands and explicit output formats. diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..441d6b7 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,409 @@ +# OpenSandbox CLI + +`osb` is the command-line interface for OpenSandbox. It is built for the common day-to-day flows: + +- create and manage sandboxes +- run commands inside a sandbox +- read and modify sandbox files +- inspect runtime egress policy +- manage sandbox-local Credential Vault state +- collect low-level diagnostics +- install OpenSandbox-specific skills for coding agents + +It uses the OpenSandbox Python SDK under the hood and is intended to be the shortest path from a terminal to a working sandbox workflow. + +## Install + +Choose one: + +```bash +pip install opensandbox-cli +``` + +```bash +uv tool install opensandbox-cli +``` + +```bash +pipx install opensandbox-cli +``` + +Confirm the install: + +```bash +osb --help +osb --version +``` + +## Before You Start + +Make sure an OpenSandbox server is reachable. If you are running locally, start the server first and then point the CLI at it. + +```bash +opensandbox-server +``` + +## Quick Start + +### 1. Initialize config + +```bash +osb config init +osb config set connection.domain localhost:8080 +osb config set connection.protocol http +osb config set connection.api_key +osb config show -o json +``` + +If you want a non-default config file, choose it at the root command level for the whole invocation: + +```bash +osb --config /tmp/dev.toml config init +osb --config /tmp/dev.toml config set connection.domain localhost:8080 +osb --config /tmp/dev.toml config show -o json +``` + +### 2. Create a sandbox + +```bash +osb sandbox create --image python:3.12 --timeout 30m -o json +``` + +If you set defaults first, later create commands can be shorter: + +```bash +osb config set defaults.image python:3.12 +osb config set defaults.timeout 30m +osb sandbox create -o json +``` + +### 3. Verify it is usable + +```bash +osb sandbox get -o json +osb sandbox health -o json +``` + +### 4. Run a command inside the sandbox + +Use `--` before the sandbox command payload. + +```bash +osb command run -o raw -- python -c "print(1 + 1)" +``` + +### 5. Read or write a file + +```bash +osb file write /workspace/hello.txt -c "hello" -o json +osb file cat /workspace/hello.txt -o raw +``` + +### 6. Clean up + +```bash +osb sandbox kill -o json +``` + +## Common Tasks + +### Create sandboxes + +Basic: + +```bash +osb sandbox create --image python:3.12 +``` + +Private image: + +```bash +osb sandbox create \ + --image my-registry.example.com/team/app:latest \ + --image-auth-username alice \ + --image-auth-password +``` + +Manual cleanup mode: + +```bash +osb sandbox create --image python:3.12 --timeout none +``` + +Explicit entrypoint argv: + +```bash +osb sandbox create \ + --image python:3.12 \ + --entrypoint python \ + --entrypoint -m \ + --entrypoint http.server +``` + +Create with network policy and volumes: + +```bash +osb sandbox create \ + --image python:3.12 \ + --network-policy-file network-policy.json \ + --volumes-file volumes.json +``` + +Create with Credential Vault proxy enabled: + +```bash +osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json +``` + +### List and inspect sandboxes + +```bash +osb sandbox list +osb sandbox list -o json +osb sandbox list --state running --state paused +osb sandbox get -o json +osb sandbox metrics +osb sandbox metrics --watch -o raw +``` + +### Expose a service + +```bash +osb sandbox endpoint --port 8080 -o json +``` + +### Run commands + +Foreground streaming: + +```bash +osb command run -o raw -- sh -lc 'echo ready' +``` + +Tracked background execution: + +```bash +osb command run --background -o json -- sh -c "sleep 10; echo done" +osb command status -o json +osb command logs -o json +``` + +Persistent shell session: + +```bash +osb command session create --workdir /workspace -o json +osb command session run -o raw -- pwd +osb command session run -o raw -- export FOO=bar +osb command session run -o raw -- sh -c 'echo $FOO' +osb command session delete -o json +``` + +### Work with files + +```bash +osb file upload ./local.txt /workspace/local.txt -o json +osb file download /workspace/result.json ./result.json -o json +osb file search /workspace --pattern "*.py" -o json +osb file info /workspace/main.py -o json +osb file replace /workspace/app.py --old old --new new -o json +osb file chmod /workspace/script.sh --mode 755 -o json +``` + +### Manage runtime egress policy + +Inspect current policy: + +```bash +osb egress get -o json +``` + +Patch specific rules: + +```bash +osb egress patch --rule allow=pypi.org --rule deny=internal.example.com -o json +``` + +If you are debugging connectivity, verify behavior with an actual command: + +```bash +osb command run -o raw -- curl -I https://pypi.org +``` + +### Manage Credential Vault + +Credential Vault operations call the sandbox egress sidecar through the Python SDK. +Create the sandbox with `--credential-proxy` and an explicit network policy before +writing vault state. + +```bash +osb credential-vault create --file vault.yaml -o json +osb credential-vault get -o json +osb credential-vault patch --file mutation.yaml -o json +osb credential-vault credential list -o json +osb credential-vault binding list -o json +osb credential-vault delete -o json +``` + +Use `--file -` to read a JSON/YAML payload from stdin. Do not pass plaintext +credential values as command-line flags; keep them in the payload stream or file. + +### Collect diagnostics + +Use the stable diagnostics commands for API-backed log and event descriptors. + +```bash +osb diagnostics events --scope lifecycle -o raw +osb diagnostics events --scope runtime -o raw +osb diagnostics logs --scope container -o raw +osb diagnostics logs --scope lifecycle -o json +osb diagnostics events --scope runtime -o json +osb diagnostics logs --scope container -o yaml +``` + +`--scope` is required for stable diagnostics. Common scopes are `lifecycle` and +`container` for logs, and `lifecycle` and `runtime` for events. Raw output +prints inline diagnostic text, or the content URL when diagnostics are +delivered as a temporary URL. Structured CLI output follows the SDK/Python field +style, for example `content_url`, `content_length`, and `expires_at`. +Some server builds may return `DIAGNOSTICS_NOT_IMPLEMENTED` for scoped +diagnostics until the stable backend implementation is enabled. + +Legacy DevOps diagnostics remain experimental. Prefer `osb diagnostics logs/events` +for stable API-backed log and event collection. + +```bash +osb devops inspect -o raw +osb devops summary -o raw +``` + +## Output Formats + +Output selection is command-scoped, not global. + +- `table`: human-readable tables and panels +- `json`: machine-readable JSON +- `yaml`: machine-readable YAML +- `raw`: unformatted text or streaming output + +Examples: + +```bash +osb sandbox list -o json +osb sandbox list -o yaml +osb file cat /workspace/hello.txt -o raw +``` + +Not every command supports every format. Use `--help` on the specific command when in doubt. + +## Command Groups + +The main command groups are: + +- `osb sandbox`: lifecycle management +- `osb command`: command execution and persistent sessions +- `osb file`: file and directory operations +- `osb egress`: runtime egress policy +- `osb diagnostics`: stable diagnostics logs and events +- `osb devops`: experimental legacy diagnostics +- `osb config`: local CLI configuration +- `osb skills`: bundled skills for AI tools + +Explore them directly: + +```bash +osb sandbox --help +osb command --help +osb file --help +osb skills --help +``` + +## Agent Skills + +The CLI ships with built-in OpenSandbox skills for coding agents and agent-oriented tools. + +Bundled skills: + +- `sandbox-lifecycle` +- `command-execution` +- `file-operations` +- `network-egress` +- `sandbox-troubleshooting` + +Supported targets: + +| Target | Install location | +| --- | --- | +| `claude` | `./.claude/skills/` or `~/.claude/skills/` | +| `cursor` | `./.cursor/rules/` or `~/.cursor/rules/` | +| `codex` | `./.codex/skills//SKILL.md` or `~/.codex/skills//SKILL.md` | +| `copilot` | `./.github/copilot-instructions.md` or `~/.github/copilot-instructions.md` | +| `windsurf` | `./.windsurfrules` or `~/.windsurfrules` | +| `cline` | `./.clinerules` or `~/.clinerules` | +| `opencode` | `./.agents/skills//SKILL.md` or `~/.agents/skills//SKILL.md` | + +Common flows: + +```bash +osb skills list +osb skills show sandbox-lifecycle +osb skills install sandbox-lifecycle --target codex --scope project +osb skills install --all-builtins --target codex --scope global +osb skills uninstall sandbox-troubleshooting --target claude --scope global +``` + +For scripts or agents, use structured output: + +```bash +osb skills install sandbox-lifecycle --target codex --scope project -o json +``` + +## Configuration Model + +The CLI resolves configuration in this order: + +1. root CLI flags such as `--api-key`, `--domain`, `--protocol`, `--request-timeout`, `--config` +2. environment variables such as `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN` +3. config file, defaulting to `~/.opensandbox/config.toml` +4. SDK defaults + +Config commands: + +```bash +osb config init +osb config show +osb config set connection.domain localhost:8080 +osb config set connection.protocol http +osb config set defaults.image python:3.12 +osb config set defaults.timeout 30m +``` + +Example config file: + +```toml +[connection] +api_key = "your-api-key" +domain = "localhost:8080" +protocol = "http" +request_timeout = 30 +use_server_proxy = false + +[output] +color = true + +[defaults] +image = "python:3.12" +timeout = "30m" +``` + +## Development + +For local development in this monorepo: + +```bash +cd cli +uv sync +uv run osb --help +uv run pytest +``` + +This repository uses a local `uv` source override for the OpenSandbox Python SDK, so running from `cli/` will resolve against the checked-out SDK in the monorepo. diff --git a/cli/assets/cli_create_sandbox.png b/cli/assets/cli_create_sandbox.png new file mode 100644 index 0000000..a20baf1 Binary files /dev/null and b/cli/assets/cli_create_sandbox.png differ diff --git a/cli/assets/cli_devops_summary_1.png b/cli/assets/cli_devops_summary_1.png new file mode 100644 index 0000000..78dd412 Binary files /dev/null and b/cli/assets/cli_devops_summary_1.png differ diff --git a/cli/assets/cli_devops_summary_2.png b/cli/assets/cli_devops_summary_2.png new file mode 100644 index 0000000..d0e2205 Binary files /dev/null and b/cli/assets/cli_devops_summary_2.png differ diff --git a/cli/assets/cli_help.png b/cli/assets/cli_help.png new file mode 100644 index 0000000..4878645 Binary files /dev/null and b/cli/assets/cli_help.png differ diff --git a/cli/assets/cli_kill_sandbox.png b/cli/assets/cli_kill_sandbox.png new file mode 100644 index 0000000..4db7500 Binary files /dev/null and b/cli/assets/cli_kill_sandbox.png differ diff --git a/cli/assets/cli_list_sandbox.png b/cli/assets/cli_list_sandbox.png new file mode 100644 index 0000000..2e186e6 Binary files /dev/null and b/cli/assets/cli_list_sandbox.png differ diff --git a/cli/assets/cli_list_sandbox_json.png b/cli/assets/cli_list_sandbox_json.png new file mode 100644 index 0000000..235b9b6 Binary files /dev/null and b/cli/assets/cli_list_sandbox_json.png differ diff --git a/cli/assets/cli_sandbox_exec.png b/cli/assets/cli_sandbox_exec.png new file mode 100644 index 0000000..659abbc Binary files /dev/null and b/cli/assets/cli_sandbox_exec.png differ diff --git a/cli/assets/cli_sandbox_file.png b/cli/assets/cli_sandbox_file.png new file mode 100644 index 0000000..ce173b1 Binary files /dev/null and b/cli/assets/cli_sandbox_file.png differ diff --git a/cli/assets/cli_sandbox_search.png b/cli/assets/cli_sandbox_search.png new file mode 100644 index 0000000..4ce364e Binary files /dev/null and b/cli/assets/cli_sandbox_search.png differ diff --git a/cli/assets/init_cli.png b/cli/assets/init_cli.png new file mode 100644 index 0000000..58a067d Binary files /dev/null and b/cli/assets/init_cli.png differ diff --git a/cli/assets/install_cli.png b/cli/assets/install_cli.png new file mode 100644 index 0000000..587f62c Binary files /dev/null and b/cli/assets/install_cli.png differ diff --git a/cli/assets/start_opensandbox_server.png b/cli/assets/start_opensandbox_server.png new file mode 100644 index 0000000..2568b14 Binary files /dev/null and b/cli/assets/start_opensandbox_server.png differ diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..459f149 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,136 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "opensandbox-cli" +dynamic = ["version"] +description = "OpenSandbox CLI - Command-line interface for managing sandboxes" +authors = [ + { name = "OpenSandbox Team", email = "ninan.nn@alibaba-inc.com" } +] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.10" +keywords = ["sandbox", "cli", "opensandbox"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", +] +dependencies = [ + "opensandbox>=0.1.9,<0.2.0", + "click>=8.1.0,<9.0", + "rich>=13.0.0,<14.0", + "pyyaml>=6.0,<7.0", + "tomli>=2.0.0; python_version < '3.11'", +] + +[project.urls] +Homepage = "https://open-sandbox.ai" +Repository = "https://github.com/opensandbox-group/OpenSandbox" +Documentation = "https://open-sandbox.ai" +Issues = "https://github.com/opensandbox-group/OpenSandbox/issues" + +[project.scripts] +opensandbox = "opensandbox_cli.main:cli" +osb = "opensandbox_cli.main:cli" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.version.raw-options] +root = ".." +tag_regex = "^cli/v(?P\\d+\\.\\d+\\.\\d+(?:[\\.\\w\\+\\-]*)?)$" +git_describe_command = 'git describe --dirty --tags --long --match "cli/v*"' +fallback_version = "0.1.1" + +[tool.hatch.build] +include = [ + "LICENSE", + "src/**/py.typed", + "src/opensandbox_cli", + "src/opensandbox_cli/skills/**", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opensandbox_cli"] + +[dependency-groups] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "ruff>=0.14.8", + "pyright>=1.1.0", +] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by formatter + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] + +[tool.pyright] +typeCheckingMode = "standard" +pythonVersion = "3.10" +pythonPlatform = "All" +include = ["src"] +exclude = [ + "**/node_modules", + "**/__pycache__", +] +reportMissingImports = true +reportMissingTypeStubs = false + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q --strict-markers --strict-config" +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.uv.sources] +opensandbox = { path = "../sdks/sandbox/python", editable = true } diff --git a/cli/src/opensandbox_cli/__init__.py b/cli/src/opensandbox_cli/__init__.py new file mode 100644 index 0000000..6a37d52 --- /dev/null +++ b/cli/src/opensandbox_cli/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +try: + from importlib.metadata import version + + __version__ = version("opensandbox-cli") +except Exception: + __version__ = "0.0.0-dev" diff --git a/cli/src/opensandbox_cli/__main__.py b/cli/src/opensandbox_cli/__main__.py new file mode 100644 index 0000000..19a82b8 --- /dev/null +++ b/cli/src/opensandbox_cli/__main__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Allow running as ``python -m opensandbox_cli``.""" + +from opensandbox_cli.main import cli + +if __name__ == "__main__": + cli() diff --git a/cli/src/opensandbox_cli/client.py b/cli/src/opensandbox_cli/client.py new file mode 100644 index 0000000..4ea1c13 --- /dev/null +++ b/cli/src/opensandbox_cli/client.py @@ -0,0 +1,127 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""SDK client factory stored in Click context.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import timedelta +from pathlib import Path +from typing import Any + +import httpx +from opensandbox.config.connection_sync import ConnectionConfigSync +from opensandbox.sync.manager import SandboxManagerSync +from opensandbox.sync.sandbox import SandboxSync + +from opensandbox_cli.output import OutputFormatter + + +@dataclass +class ClientContext: + """Shared context passed via ``ctx.obj`` to all Click commands.""" + + resolved_config: dict[str, Any] + config_path: Path + cli_overrides: dict[str, Any] + output: OutputFormatter = field(init=False) + _connection_config: ConnectionConfigSync | None = field( + default=None, init=False, repr=False + ) + _manager: SandboxManagerSync | None = field( + default=None, init=False, repr=False + ) + _devops_client: httpx.Client | None = field( + default=None, init=False, repr=False + ) + + def __post_init__(self) -> None: + """Initialize a default human-readable formatter for the command context.""" + self.output = OutputFormatter( + "table", + color=self.resolved_config.get("color", True), + ) + + @property + def connection_config(self) -> ConnectionConfigSync: + if self._connection_config is None: + cfg = self.resolved_config + self._connection_config = ConnectionConfigSync( + api_key=cfg.get("api_key"), + domain=cfg.get("domain"), + protocol=cfg.get("protocol", "http"), + request_timeout=timedelta(seconds=cfg.get("request_timeout", 30)), + use_server_proxy=cfg.get("use_server_proxy", False), + ) + return self._connection_config + + def get_devops_client(self) -> httpx.Client: + """Return a cached HTTP client for experimental diagnostics endpoints.""" + if self._devops_client is None: + config = self.connection_config + headers = dict(config.headers) + headers.setdefault("Accept", "text/plain") + headers.setdefault("User-Agent", config.user_agent) + if config.api_key: + headers["OPEN-SANDBOX-API-KEY"] = config.api_key + + self._devops_client = httpx.Client( + base_url=config.get_base_url(), + headers=headers, + timeout=config.request_timeout.total_seconds(), + ) + return self._devops_client + + def get_manager(self) -> SandboxManagerSync: + """Return a lazily-created ``SandboxManagerSync``.""" + if self._manager is None: + self._manager = SandboxManagerSync.create(self.connection_config) + return self._manager + + def resolve_sandbox_id(self, sandbox_id: str) -> str: + """Return the sandbox ID exactly as provided by the user.""" + return sandbox_id + + def connect_sandbox( + self, sandbox_id: str, *, skip_health_check: bool = True + ) -> SandboxSync: + """Connect to an existing sandbox by ID.""" + sandbox_id = self.resolve_sandbox_id(sandbox_id) + return SandboxSync.connect( + sandbox_id, + connection_config=self.connection_config, + skip_health_check=skip_health_check, + ) + + def make_output(self, fmt: str) -> OutputFormatter: + """Create and cache the current command-scoped output formatter.""" + formatter = OutputFormatter( + fmt, + color=self.resolved_config.get("color", True), + ) + self.output = formatter + return formatter + + def close(self) -> None: + """Release resources.""" + if self._manager is not None: + self._manager.close() + self._manager = None + if self._devops_client is not None: + self._devops_client.close() + self._devops_client = None + if self._connection_config is not None: + self._connection_config.close_transport_if_owned() + self._connection_config = None diff --git a/cli/src/opensandbox_cli/commands/__init__.py b/cli/src/opensandbox_cli/commands/__init__.py new file mode 100644 index 0000000..d5e97d7 --- /dev/null +++ b/cli/src/opensandbox_cli/commands/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/cli/src/opensandbox_cli/commands/command.py b/cli/src/opensandbox_cli/commands/command.py new file mode 100644 index 0000000..e95ce6b --- /dev/null +++ b/cli/src/opensandbox_cli/commands/command.py @@ -0,0 +1,359 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Command execution commands: one interface, foreground stream or background tracking.""" + +from __future__ import annotations + +import shlex +import sys +from datetime import timedelta + +import click +from opensandbox.models.execd import OutputMessage, RunCommandOpts +from opensandbox.models.execd_sync import ExecutionHandlersSync + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import ( + DURATION, + handle_errors, + output_option, + prepare_output, +) + + +@click.group("command", invoke_without_command=True) +@click.pass_context +def command_group(ctx: click.Context) -> None: + """⚡ Execute commands in a sandbox.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# ---- run ------------------------------------------------------------------ + +def _run_command( + obj: ClientContext, + sandbox_id: str, + command: tuple[str, ...], + background: bool, + workdir: str | None, + timeout: timedelta | None, + output_format: str | None, +) -> None: + """Shared implementation for ``command run``. + + Mode contract: + - foreground (default): stream output directly, allow only ``-o raw`` + - background (``--background``): return a tracked execution object, allow structured output + """ + allowed = ("table", "json", "yaml") if background else ("raw",) + fallback = "table" if background else "raw" + prepare_output(obj, output_format, allowed=allowed, fallback=fallback) + cmd_str = " ".join(shlex.quote(arg) for arg in command) + sandbox = obj.connect_sandbox(sandbox_id) + + try: + opts = RunCommandOpts( + background=background, + working_directory=workdir, + timeout=timeout, + ) + + if background: + execution = sandbox.commands.run(cmd_str, opts=opts) + obj.output.success_panel( + { + "execution_id": execution.id, + "sandbox_id": sandbox_id, + "mode": "background", + }, + title="Background Command Started", + ) + return + + # Foreground: stream stdout/stderr to terminal + last_text = "" + + def on_stdout(msg: OutputMessage) -> None: + nonlocal last_text + last_text = msg.text + sys.stdout.write(msg.text) + sys.stdout.flush() + + def on_stderr(msg: OutputMessage) -> None: + nonlocal last_text + last_text = msg.text + sys.stderr.write(msg.text) + sys.stderr.flush() + + handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr) + execution = sandbox.commands.run(cmd_str, opts=opts, handlers=handlers) + + # Ensure terminal prompt starts on a new line + if last_text and not last_text.endswith("\n"): + sys.stdout.write("\n") + sys.stdout.flush() + + _handle_execution_error(obj, execution) + finally: + sandbox.close() + + +def _handle_execution_error(obj: ClientContext, execution) -> None: + """Exit non-zero if the execution finished with an error.""" + if execution.error: + obj.output.error_panel( + f"{execution.error.name}: {execution.error.value}", + title="Execution Error", + ) + sys.exit(1) + + +@command_group.command( + "run", + help="Run a command in a sandbox. Use `--` before the sandbox command payload.", + epilog="Separator rule: use `--` before the sandbox command payload.", +) +@click.argument("sandbox_id") +@click.argument("command", nargs=-1, required=True) +@click.option("-d", "--background", is_flag=True, default=False, help="Run in background.") +@click.option("-w", "--workdir", default=None, help="Working directory.") +@click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).") +@output_option("table", "json", "yaml", "raw") +@click.pass_obj +@handle_errors +def command_run( + obj: ClientContext, + sandbox_id: str, + command: tuple[str, ...], + background: bool, + workdir: str | None, + timeout: timedelta | None, + output_format: str | None, +) -> None: + """Run a command in a sandbox. + + Default mode streams output directly. Add ``--background`` to return a + tracked execution object instead. Use ``--`` before the sandbox command payload. + """ + _run_command( + obj, + sandbox_id, + command, + background, + workdir, + timeout, + output_format, + ) + + +# ---- status --------------------------------------------------------------- + +@command_group.command("status") +@click.argument("sandbox_id") +@click.argument("execution_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def command_status( + obj: ClientContext, + sandbox_id: str, + execution_id: str, + output_format: str | None, +) -> None: + """Get command execution status.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + status = sandbox.commands.get_command_status(execution_id) + obj.output.print_model(status, title="Command Status") + finally: + sandbox.close() + + +# ---- logs ----------------------------------------------------------------- + +@command_group.command("logs") +@click.argument("sandbox_id") +@click.argument("execution_id") +@click.option("--cursor", type=int, default=None, help="Cursor for incremental reads.") +@output_option("table", "json", "yaml", "raw") +@click.pass_obj +@handle_errors +def command_logs( + obj: ClientContext, + sandbox_id: str, + execution_id: str, + cursor: int | None, + output_format: str | None, +) -> None: + """Get background command logs.""" + prepare_output( + obj, output_format, allowed=("table", "json", "yaml", "raw"), fallback="table" + ) + sandbox = obj.connect_sandbox(sandbox_id) + try: + logs = sandbox.commands.get_background_command_logs(execution_id, cursor=cursor) + if obj.output.fmt in ("json", "yaml"): + obj.output.print_model(logs, title="Command Logs") + elif obj.output.fmt == "raw": + click.echo(logs.content) + else: + obj.output.panel(logs.content, title="Command Logs") + finally: + sandbox.close() + + +# ---- interrupt ------------------------------------------------------------ + +@command_group.command("interrupt") +@click.argument("sandbox_id") +@click.argument("execution_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def command_interrupt( + obj: ClientContext, + sandbox_id: str, + execution_id: str, + output_format: str | None, +) -> None: + """Interrupt a running command.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.commands.interrupt(execution_id) + obj.output.success(f"Interrupted: {execution_id}") + finally: + sandbox.close() + + +@command_group.group("session", invoke_without_command=True) +@click.pass_context +def session_group(ctx: click.Context) -> None: + """Manage persistent bash sessions.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@session_group.command("create") +@click.argument("sandbox_id") +@click.option("-w", "--workdir", default=None, help="Initial working directory.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def session_create( + obj: ClientContext, + sandbox_id: str, + workdir: str | None, + output_format: str | None, +) -> None: + """Create a persistent bash session.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + session_id = sandbox.commands.create_session(working_directory=workdir) + obj.output.success_panel( + { + "sandbox_id": sandbox.id, + "session_id": session_id, + "working_directory": workdir, + }, + title="Session Created", + ) + finally: + sandbox.close() + + +@session_group.command( + "run", + help="Run a command in an existing bash session. Use `--` before the sandbox command payload.", + epilog="Separator rule: use `--` before the sandbox command payload.", +) +@click.argument("sandbox_id") +@click.argument("session_id") +@click.argument("command", nargs=-1, required=True) +@click.option("-w", "--workdir", default=None, help="Working directory override for this run.") +@click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).") +@output_option("raw", help_text="Output format: raw.") +@click.pass_obj +@handle_errors +def session_run( + obj: ClientContext, + sandbox_id: str, + session_id: str, + command: tuple[str, ...], + workdir: str | None, + timeout: timedelta | None, + output_format: str | None, +) -> None: + """Run a command in an existing bash session. Use `--` before the sandbox command payload.""" + prepare_output(obj, output_format, allowed=("raw",), fallback="raw") + cmd_str = " ".join(shlex.quote(arg) for arg in command) + sandbox = obj.connect_sandbox(sandbox_id) + try: + last_text = "" + + def on_stdout(msg: OutputMessage) -> None: + nonlocal last_text + last_text = msg.text + sys.stdout.write(msg.text) + sys.stdout.flush() + + def on_stderr(msg: OutputMessage) -> None: + nonlocal last_text + last_text = msg.text + sys.stderr.write(msg.text) + sys.stderr.flush() + + handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr) + execution = sandbox.commands.run_in_session( + session_id, + cmd_str, + working_directory=workdir, + timeout=timeout, + handlers=handlers, + ) + + if last_text and not last_text.endswith("\n"): + sys.stdout.write("\n") + sys.stdout.flush() + + _handle_execution_error(obj, execution) + finally: + sandbox.close() + + +@session_group.command("delete") +@click.argument("sandbox_id") +@click.argument("session_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def session_delete( + obj: ClientContext, + sandbox_id: str, + session_id: str, + output_format: str | None, +) -> None: + """Delete a persistent bash session.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.commands.delete_session(session_id) + obj.output.success(f"Deleted session: {session_id}") + finally: + sandbox.close() + diff --git a/cli/src/opensandbox_cli/commands/config_cmd.py b/cli/src/opensandbox_cli/commands/config_cmd.py new file mode 100644 index 0000000..b5b08ef --- /dev/null +++ b/cli/src/opensandbox_cli/commands/config_cmd.py @@ -0,0 +1,183 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Config management commands: init, show, set.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import click + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.config import init_config_file, resolve_config +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +@click.group("config", invoke_without_command=True) +@click.pass_context +def config_group(ctx: click.Context) -> None: + """⚙️ Manage CLI configuration.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# ---- init ----------------------------------------------------------------- + +@config_group.command("init") +@click.option("--force", is_flag=True, default=False, help="Overwrite existing config file.") +@output_option("table", "json", "yaml") +@handle_errors +@click.pass_obj +def config_init(obj: ClientContext, force: bool, output_format: str | None) -> None: + """Create a default configuration file.""" + output = prepare_output( + obj, output_format, allowed=("table", "json", "yaml"), fallback="table" + ) + + try: + path = init_config_file(obj.config_path, force=force) + output.success(f"Config file created: {path}") + except FileExistsError as exc: + output.warning(str(exc)) + + +# ---- show ----------------------------------------------------------------- + +_SENSITIVE_CONFIG_KEYS = { + "api_key", +} + + +def _mask_secret(value: str | None) -> str | None: + """Mask a secret while keeping enough shape for debugging.""" + if value is None: + return None + if len(value) <= 4: + return "*" * len(value) + return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}" + + +def _sanitize_config_for_display(data: Mapping[str, Any]) -> dict[str, Any]: + """Return a display-safe copy of the resolved config.""" + sanitized: dict[str, Any] = {} + for key, value in data.items(): + if key in _SENSITIVE_CONFIG_KEYS: + sanitized[key] = _mask_secret(value if isinstance(value, str) else None) + continue + sanitized[key] = value + return sanitized + +@config_group.command("show") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def config_show(obj: ClientContext, output_format: str | None) -> None: + """Show the resolved configuration.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + resolved = resolve_config( + cli_api_key=obj.cli_overrides.get("api_key"), + cli_domain=obj.cli_overrides.get("domain"), + cli_protocol=obj.cli_overrides.get("protocol"), + cli_timeout=obj.cli_overrides.get("request_timeout"), + cli_use_server_proxy=obj.cli_overrides.get("use_server_proxy"), + config_path=obj.config_path, + ) + obj.output.print_dict( + { + **_sanitize_config_for_display(resolved), + "config_path": str(obj.config_path), + "config_file_exists": obj.config_path.exists(), + }, + title="Resolved Configuration", + ) + + +# ---- set ------------------------------------------------------------------ + +@config_group.command("set") +@click.argument("key") +@click.argument("value") +@output_option("table", "json", "yaml") +@handle_errors +@click.pass_obj +def config_set( + obj: ClientContext, + key: str, + value: str, + output_format: str | None, +) -> None: + """Set a configuration value (e.g. 'connection.domain' 'localhost:9090').""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + path = obj.config_path + if not path.exists(): + raise click.ClickException(f"Config file not found: {path}. Run 'osb config init' first.") + + content = path.read_text() + + # TODO: Replace this regex-based TOML editing with a parser-backed update + # path so formatting/comments survive reliably as config complexity grows. + # Simple key replacement in TOML + # Supports dotted keys like connection.domain + parts = key.split(".", 1) + if len(parts) == 2: + section, field = parts + # Try to find and update existing value + import re + + section_pattern = rf"(\[{re.escape(section)}\].*?)(?=\n\[|\Z)" + section_match = re.search(section_pattern, content, re.DOTALL) + + # Infer TOML value type: bool > int > float > string + def _toml_value(raw: str) -> str: + if raw.lower() in ("true", "false"): + return raw.lower() + try: + int(raw) + return raw + except ValueError: + pass + try: + float(raw) + return raw + except ValueError: + pass + return f'"{raw}"' + + toml_val = _toml_value(value) + + if section_match: + section_text = section_match.group(1) + field_pattern = rf'^(#?\s*{re.escape(field)}\s*=\s*).*$' + field_match = re.search(field_pattern, section_text, re.MULTILINE) + if field_match: + new_line = f'{field} = {toml_val}' + new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():] + content = content[:section_match.start()] + new_section + content[section_match.end():] + else: + # Add field to section + insert_pos = section_match.end() + content = content[:insert_pos] + f'\n{field} = {toml_val}' + content[insert_pos:] + else: + # Add new section + content += f'\n[{section}]\n{field} = {toml_val}\n' + else: + raise click.ClickException( + "Key must be in 'section.field' format (e.g. connection.domain)." + ) + + path.write_text(content) + + obj.output.success(f"Set {key} = {value}") diff --git a/cli/src/opensandbox_cli/commands/credential_vault.py b/cli/src/opensandbox_cli/commands/credential_vault.py new file mode 100644 index 0000000..5a19acc --- /dev/null +++ b/cli/src/opensandbox_cli/commands/credential_vault.py @@ -0,0 +1,306 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sandbox-local Credential Vault commands.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import click +import yaml + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +def _read_payload(payload_file: str) -> dict[str, Any]: + """Read a JSON/YAML object from a file path or stdin.""" + try: + if payload_file == "-": + raw = click.get_text_stream("stdin").read() + else: + path = Path(payload_file) + if not path.is_file(): + raise click.ClickException(f"Payload file not found: {payload_file}") + raw = path.read_text(encoding="utf-8") + payload = yaml.safe_load(raw) + except click.ClickException: + raise + except Exception as exc: + raise click.ClickException( + f"Failed to read credential vault payload from {payload_file}." + ) from exc + + if not isinstance(payload, dict): + raise click.ClickException("Credential vault payload must be a JSON/YAML object.") + return payload + + +def _require_list(payload: dict[str, Any], key: str) -> list[Any]: + value = payload.get(key) + if not isinstance(value, list): + raise click.ClickException(f"Credential vault payload field '{key}' must be a list.") + return value + + +def _get_expected_revision(payload: dict[str, Any]) -> int | None: + raw = payload.get("expectedRevision", payload.get("expected_revision")) + if raw is None: + return None + if type(raw) is not int: + raise click.ClickException("Credential vault expectedRevision must be an integer.") + return raw + + +def _optional_object(payload: dict[str, Any], key: str) -> dict[str, Any] | None: + value = payload.get(key) + if value is None: + return None + if not isinstance(value, dict): + raise click.ClickException(f"Credential vault patch field '{key}' must be an object.") + return value + + +@click.group("credential-vault", invoke_without_command=True) +@click.pass_context +def credential_vault_group(ctx: click.Context) -> None: + """Manage sandbox-local Credential Vault state.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@credential_vault_group.command("create") +@click.argument("sandbox_id") +@click.option( + "--file", + "payload_file", + required=True, + help="JSON/YAML payload file, or '-' to read from stdin.", +) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def credential_vault_create( + obj: ClientContext, + sandbox_id: str, + payload_file: str, + output_format: str | None, +) -> None: + """Create the initial Credential Vault state.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + payload = _read_payload(payload_file) + credentials = _require_list(payload, "credentials") + bindings = _require_list(payload, "bindings") + + sandbox = obj.connect_sandbox(sandbox_id) + try: + state = sandbox.credential_vault.create( + credentials=credentials, + bindings=bindings, + ) + obj.output.print_model(state, title="Credential Vault") + finally: + sandbox.close() + + +@credential_vault_group.command("get") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def credential_vault_get( + obj: ClientContext, + sandbox_id: str, + output_format: str | None, +) -> None: + """Get sanitized Credential Vault state.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + state = sandbox.credential_vault.get() + obj.output.print_model(state, title="Credential Vault") + finally: + sandbox.close() + + +@credential_vault_group.command("patch") +@click.argument("sandbox_id") +@click.option( + "--file", + "payload_file", + required=True, + help="JSON/YAML mutation payload file, or '-' to read from stdin.", +) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def credential_vault_patch( + obj: ClientContext, + sandbox_id: str, + payload_file: str, + output_format: str | None, +) -> None: + """Atomically mutate credentials and bindings.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + payload = _read_payload(payload_file) + credentials = _optional_object(payload, "credentials") + bindings = _optional_object(payload, "bindings") + if credentials is None and bindings is None: + raise click.ClickException( + "Credential vault patch payload must include credentials or bindings." + ) + + sandbox = obj.connect_sandbox(sandbox_id) + try: + state = sandbox.credential_vault.patch( + expected_revision=_get_expected_revision(payload), + credentials=credentials, + bindings=bindings, + ) + obj.output.print_model(state, title="Credential Vault") + finally: + sandbox.close() + + +@credential_vault_group.command("delete") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def credential_vault_delete( + obj: ClientContext, + sandbox_id: str, + output_format: str | None, +) -> None: + """Delete the sandbox-local Credential Vault.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.credential_vault.delete() + obj.output.success_panel( + {"sandbox_id": sandbox.id, "status": "deleted"}, + title="Credential Vault Deleted", + ) + finally: + sandbox.close() + + +@credential_vault_group.group("credential", invoke_without_command=True) +@click.pass_context +def credential_group(ctx: click.Context) -> None: + """Inspect sanitized Credential Vault credential metadata.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@credential_group.command("list") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def credential_list( + obj: ClientContext, + sandbox_id: str, + output_format: str | None, +) -> None: + """List sanitized credential metadata.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + credentials = sandbox.credential_vault.list_credentials() + obj.output.print_models( + credentials, + columns=["name", "source_type", "revision"], + title="Credential Vault Credentials", + ) + finally: + sandbox.close() + + +@credential_group.command("get") +@click.argument("sandbox_id") +@click.argument("credential_name") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def credential_get( + obj: ClientContext, + sandbox_id: str, + credential_name: str, + output_format: str | None, +) -> None: + """Get sanitized metadata for one credential.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + credential = sandbox.credential_vault.get_credential(credential_name) + obj.output.print_model(credential, title="Credential Vault Credential") + finally: + sandbox.close() + + +@credential_vault_group.group("binding", invoke_without_command=True) +@click.pass_context +def binding_group(ctx: click.Context) -> None: + """Inspect sanitized Credential Vault binding metadata.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@binding_group.command("list") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def binding_list( + obj: ClientContext, + sandbox_id: str, + output_format: str | None, +) -> None: + """List sanitized binding metadata.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + bindings = sandbox.credential_vault.list_bindings() + obj.output.print_models( + bindings, + columns=["name", "revision", "match", "auth"], + title="Credential Vault Bindings", + ) + finally: + sandbox.close() + + +@binding_group.command("get") +@click.argument("sandbox_id") +@click.argument("binding_name") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def binding_get( + obj: ClientContext, + sandbox_id: str, + binding_name: str, + output_format: str | None, +) -> None: + """Get sanitized metadata for one binding.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + binding = sandbox.credential_vault.get_binding(binding_name) + obj.output.print_model(binding, title="Credential Vault Binding") + finally: + sandbox.close() diff --git a/cli/src/opensandbox_cli/commands/devops.py b/cli/src/opensandbox_cli/commands/devops.py new file mode 100644 index 0000000..54d38f5 --- /dev/null +++ b/cli/src/opensandbox_cli/commands/devops.py @@ -0,0 +1,125 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Experimental DevOps diagnostics commands: logs, inspect, events, summary.""" + +from __future__ import annotations + +import click + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +def _fetch_plain_text(obj: ClientContext, sandbox_id: str, endpoint: str, params: dict | None = None) -> str: + """Fetch a diagnostics endpoint and return the plain-text body.""" + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + client = obj.get_devops_client() + resp = client.get(f"sandboxes/{sandbox_id}/diagnostics/{endpoint}", params=params) + if resp.status_code == 404: + raise click.ClickException(f"Sandbox '{sandbox_id}' not found.") + resp.raise_for_status() + return resp.text + + +@click.group("devops", invoke_without_command=True) +@click.pass_context +def devops_group(ctx: click.Context) -> None: + """Experimental diagnostics for sandbox troubleshooting.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# ---- logs ---------------------------------------------------------------- + +@devops_group.command("logs") +@click.argument("sandbox_id") +@click.option("--tail", "-n", type=int, default=100, show_default=True, help="Number of trailing log lines.") +@click.option("--since", "-s", default=None, help="Only logs newer than this duration (e.g. 10m, 1h).") +@output_option("raw", help_text="Output format: raw.") +@click.pass_obj +@handle_errors +def devops_logs( + obj: ClientContext, + sandbox_id: str, + tail: int, + since: str | None, + output_format: str | None, +) -> None: + """Deprecated: use `osb diagnostics logs` instead.""" + click.echo("Warning: `osb devops logs` is deprecated. Use `osb diagnostics logs`.", err=True) + prepare_output(obj, output_format, allowed=("raw",), fallback="raw") + params: dict = {"tail": tail} + if since: + params["since"] = since + text = _fetch_plain_text(obj, sandbox_id, "logs", params=params) + click.echo(text) + + +# ---- inspect ------------------------------------------------------------- + +@devops_group.command("inspect") +@click.argument("sandbox_id") +@output_option("raw", help_text="Output format: raw.") +@click.pass_obj +@handle_errors +def devops_inspect( + obj: ClientContext, sandbox_id: str, output_format: str | None +) -> None: + """Retrieve detailed container/pod inspection info.""" + prepare_output(obj, output_format, allowed=("raw",), fallback="raw") + text = _fetch_plain_text(obj, sandbox_id, "inspect") + click.echo(text) + + +# ---- events -------------------------------------------------------------- + +@devops_group.command("events") +@click.argument("sandbox_id") +@click.option("--limit", "-l", type=int, default=50, show_default=True, help="Maximum number of events.") +@output_option("raw", help_text="Output format: raw.") +@click.pass_obj +@handle_errors +def devops_events( + obj: ClientContext, sandbox_id: str, limit: int, output_format: str | None +) -> None: + """Deprecated: use `osb diagnostics events` instead.""" + click.echo("Warning: `osb devops events` is deprecated. Use `osb diagnostics events`.", err=True) + prepare_output(obj, output_format, allowed=("raw",), fallback="raw") + params: dict = {"limit": limit} + text = _fetch_plain_text(obj, sandbox_id, "events", params=params) + click.echo(text) + + +# ---- summary ------------------------------------------------------------- + +@devops_group.command("summary") +@click.argument("sandbox_id") +@click.option("--tail", "-n", type=int, default=50, show_default=True, help="Number of trailing log lines.") +@click.option("--event-limit", type=int, default=20, show_default=True, help="Maximum number of events.") +@output_option("raw", help_text="Output format: raw.") +@click.pass_obj +@handle_errors +def devops_summary( + obj: ClientContext, + sandbox_id: str, + tail: int, + event_limit: int, + output_format: str | None, +) -> None: + """One-shot diagnostics: inspect + events + logs combined.""" + prepare_output(obj, output_format, allowed=("raw",), fallback="raw") + params: dict = {"tail": tail, "event_limit": event_limit} + text = _fetch_plain_text(obj, sandbox_id, "summary", params=params) + click.echo(text) diff --git a/cli/src/opensandbox_cli/commands/diagnostics.py b/cli/src/opensandbox_cli/commands/diagnostics.py new file mode 100644 index 0000000..369aea6 --- /dev/null +++ b/cli/src/opensandbox_cli/commands/diagnostics.py @@ -0,0 +1,129 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stable sandbox diagnostics commands backed by the Python SDK.""" + +from __future__ import annotations + +from typing import Any + +import click +from opensandbox.models.diagnostics import DiagnosticContent + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +def _diagnostic_to_dict(content: DiagnosticContent) -> dict[str, Any]: + """Convert SDK diagnostics content to a CLI-friendly dict.""" + return content.model_dump(mode="json") + + +def render_diagnostic_content( + obj: ClientContext, + content: DiagnosticContent, + output_format: str | None, + *, + title: str, +) -> None: + """Render diagnostics descriptor or raw diagnostic payload.""" + output = prepare_output( + obj, + output_format, + allowed=("table", "json", "yaml", "raw"), + fallback="raw", + ) + + if output.fmt == "raw": + if content.delivery == "inline": + click.echo(content.content or "") + return + if content.content_url: + click.echo(content.content_url) + return + raise click.ClickException("Diagnostic response did not include inline content or a content URL.") + + output.print_dict(_diagnostic_to_dict(content), title=title) + + +@click.group("diagnostics", invoke_without_command=True) +@click.pass_context +def diagnostics_group(ctx: click.Context) -> None: + """Stable sandbox diagnostics backed by the OpenSandbox SDK.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@diagnostics_group.command("logs") +@click.argument("sandbox_id") +@click.option( + "--scope", + "-s", + required=True, + help=( + "Diagnostic log scope. Common scopes: lifecycle for manager logs, " + "container for sandbox stdout; other scopes are server-defined." + ), +) +@output_option( + "table", + "json", + "yaml", + "raw", + help_text="Output format. raw prints inline text, or the content URL for URL-delivered diagnostics.", +) +@click.pass_obj +@handle_errors +def diagnostics_logs( + obj: ClientContext, + sandbox_id: str, + scope: str, + output_format: str | None, +) -> None: + """Retrieve diagnostic logs for a sandbox.""" + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + content = obj.get_manager().get_diagnostic_logs(sandbox_id, scope=scope) + render_diagnostic_content(obj, content, output_format, title="Diagnostic Logs") + + +@diagnostics_group.command("events") +@click.argument("sandbox_id") +@click.option( + "--scope", + "-s", + required=True, + help=( + "Diagnostic event scope. Common scopes: lifecycle for audit events, " + "runtime for scheduler/container events; other scopes are server-defined." + ), +) +@output_option( + "table", + "json", + "yaml", + "raw", + help_text="Output format. raw prints inline text, or the content URL for URL-delivered diagnostics.", +) +@click.pass_obj +@handle_errors +def diagnostics_events( + obj: ClientContext, + sandbox_id: str, + scope: str, + output_format: str | None, +) -> None: + """Retrieve diagnostic events for a sandbox.""" + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + content = obj.get_manager().get_diagnostic_events(sandbox_id, scope=scope) + render_diagnostic_content(obj, content, output_format, title="Diagnostic Events") diff --git a/cli/src/opensandbox_cli/commands/egress.py b/cli/src/opensandbox_cli/commands/egress.py new file mode 100644 index 0000000..93e039d --- /dev/null +++ b/cli/src/opensandbox_cli/commands/egress.py @@ -0,0 +1,98 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Runtime egress policy commands.""" + +from __future__ import annotations + +import click +from opensandbox.models.sandboxes import NetworkRule + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +def _parse_rule(value: str) -> NetworkRule: + """Parse ACTION=TARGET into a NetworkRule.""" + action, sep, target = value.partition("=") + if not sep: + raise click.BadParameter( + f"Invalid rule '{value}'. Use ACTION=TARGET, for example allow=pypi.org." + ) + action = action.strip().lower() + target = target.strip() + if action not in ("allow", "deny"): + raise click.BadParameter( + f"Invalid rule action '{action}'. Use allow or deny." + ) + return NetworkRule(action=action, target=target) + + +@click.group("egress", invoke_without_command=True) +@click.pass_context +def egress_group(ctx: click.Context) -> None: + """Manage runtime egress policy for a sandbox.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@egress_group.command("get") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def egress_get(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None: + """Get the current egress policy.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + policy = sandbox.get_egress_policy() + obj.output.print_model(policy, title="Egress Policy") + finally: + sandbox.close() + + +@egress_group.command("patch") +@click.argument("sandbox_id") +@click.option( + "--rule", + "rules", + multiple=True, + required=True, + help="Patch rule in ACTION=TARGET form. Repeatable, e.g. --rule allow=pypi.org.", +) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def egress_patch( + obj: ClientContext, + sandbox_id: str, + rules: tuple[str, ...], + output_format: str | None, +) -> None: + """Patch runtime egress rules with merge semantics.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + parsed_rules = [_parse_rule(rule) for rule in rules] + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.patch_egress_rules(parsed_rules) + obj.output.success_panel( + { + "sandbox_id": sandbox.id, + "patched_rules": [rule.model_dump(mode="json") for rule in parsed_rules], + }, + title="Egress Policy Patched", + ) + finally: + sandbox.close() diff --git a/cli/src/opensandbox_cli/commands/file.py b/cli/src/opensandbox_cli/commands/file.py new file mode 100644 index 0000000..9238806 --- /dev/null +++ b/cli/src/opensandbox_cli/commands/file.py @@ -0,0 +1,442 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""File operation commands: cat, write, upload, download, rm, mv, mkdir, rmdir, search, info, chmod, replace.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +def _parse_permission_mode(mode: str) -> int: + """Parse a permission mode string like 644 or 755.""" + try: + return int(mode) + except ValueError as exc: + raise click.BadParameter( + f"Invalid permission mode '{mode}'. Use a permission value like 644 or 755." + ) from exc + + +@click.group("file", invoke_without_command=True) +@click.pass_context +def file_group(ctx: click.Context) -> None: + """📁 File operations on a sandbox.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# ---- cat (read) ----------------------------------------------------------- + +@file_group.command("cat") +@click.argument("sandbox_id") +@click.argument("path") +@click.option("--encoding", default="utf-8", help="File encoding.") +@output_option("raw", help_text="Output format: raw.") +@click.pass_obj +@handle_errors +def file_cat( + obj: ClientContext, + sandbox_id: str, + path: str, + encoding: str, + output_format: str | None, +) -> None: + """Read a file from the sandbox.""" + prepare_output(obj, output_format, allowed=("raw",), fallback="raw") + sandbox = obj.connect_sandbox(sandbox_id) + try: + content = sandbox.files.read_file(path, encoding=encoding) + click.echo(content, nl=False) + finally: + sandbox.close() + + +# ---- write ---------------------------------------------------------------- + +@file_group.command("write") +@click.argument("sandbox_id") +@click.argument("path") +@click.option("--content", "-c", default=None, help="Content to write. Reads from stdin if not provided.") +@click.option("--encoding", default="utf-8", help="File encoding.") +@click.option("--mode", default=None, help="File permission mode (e.g. 0644).") +@click.option("--owner", default=None, help="File owner.") +@click.option("--group", default=None, help="File group.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_write( + obj: ClientContext, + sandbox_id: str, + path: str, + content: str | None, + encoding: str, + mode: str | None, + owner: str | None, + group: str | None, + output_format: str | None, +) -> None: + """Write content to a file in the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + file_content = content + if file_content is None: + if sys.stdin.isatty(): + click.echo("Reading from stdin (Ctrl+D to finish):", err=True) + file_content = sys.stdin.read() + + sandbox = obj.connect_sandbox(sandbox_id) + try: + kwargs: dict = {"encoding": encoding} + if mode is not None: + kwargs["mode"] = _parse_permission_mode(mode) + if owner is not None: + kwargs["owner"] = owner + if group is not None: + kwargs["group"] = group + sandbox.files.write_file(path, file_content, **kwargs) + obj.output.success(f"Written: {path}") + finally: + sandbox.close() + + +# ---- upload --------------------------------------------------------------- + +@file_group.command("upload") +@click.argument("sandbox_id") +@click.argument("local_path", type=click.Path(exists=True)) +@click.argument("remote_path") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_upload( + obj: ClientContext, + sandbox_id: str, + local_path: str, + remote_path: str, + output_format: str | None, +) -> None: + """Upload a local file to the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + with Path(local_path).open("rb") as data: + sandbox.files.write_file(remote_path, data) + obj.output.success(f"Uploaded: {local_path} → {remote_path}") + finally: + sandbox.close() + + +# ---- download ------------------------------------------------------------- + +@file_group.command("download") +@click.argument("sandbox_id") +@click.argument("remote_path") +@click.argument("local_path", type=click.Path()) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_download( + obj: ClientContext, + sandbox_id: str, + remote_path: str, + local_path: str, + output_format: str | None, +) -> None: + """Download a file from the sandbox to local disk.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + destination = Path(local_path) + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("wb") as out: + for chunk in sandbox.files.read_bytes_stream(remote_path): + out.write(chunk) + obj.output.success(f"Downloaded: {remote_path} → {local_path}") + finally: + sandbox.close() + + +# ---- rm (delete) ---------------------------------------------------------- + +@file_group.command("rm") +@click.argument("sandbox_id") +@click.argument("paths", nargs=-1, required=True) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_rm( + obj: ClientContext, + sandbox_id: str, + paths: tuple[str, ...], + output_format: str | None, +) -> None: + """Delete files from the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.files.delete_files(list(paths)) + obj.output.print_rows( + [{"path": p, "status": "deleted"} for p in paths], + columns=["path", "status"], + title="Deleted Files", + ) + finally: + sandbox.close() + + +# ---- mv (move) ------------------------------------------------------------ + +@file_group.command("mv") +@click.argument("sandbox_id") +@click.argument("source") +@click.argument("destination") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_mv( + obj: ClientContext, + sandbox_id: str, + source: str, + destination: str, + output_format: str | None, +) -> None: + """Move/rename a file in the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + from opensandbox.models.filesystem import MoveEntry + + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.files.move_files([MoveEntry(source=source, destination=destination)]) + obj.output.success(f"Moved: {source} → {destination}") + finally: + sandbox.close() + + +# ---- mkdir ---------------------------------------------------------------- + +@file_group.command("mkdir") +@click.argument("sandbox_id") +@click.argument("paths", nargs=-1, required=True) +@click.option("--mode", default=None, help="Directory permission mode.") +@click.option("--owner", default=None, help="Directory owner.") +@click.option("--group", default=None, help="Directory group.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_mkdir( + obj: ClientContext, + sandbox_id: str, + paths: tuple[str, ...], + mode: str | None, + owner: str | None, + group: str | None, + output_format: str | None, +) -> None: + """Create directories in the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + from opensandbox.models.filesystem import WriteEntry + + sandbox = obj.connect_sandbox(sandbox_id) + try: + entries = [] + for p in paths: + kwargs: dict = {"path": p} + if mode is not None: + kwargs["mode"] = _parse_permission_mode(mode) + if owner is not None: + kwargs["owner"] = owner + if group is not None: + kwargs["group"] = group + entries.append(WriteEntry(**kwargs)) + sandbox.files.create_directories(entries) + obj.output.print_rows( + [{"path": p, "status": "created"} for p in paths], + columns=["path", "status"], + title="Created Directories", + ) + finally: + sandbox.close() + + +# ---- rmdir ---------------------------------------------------------------- + +@file_group.command("rmdir") +@click.argument("sandbox_id") +@click.argument("paths", nargs=-1, required=True) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_rmdir( + obj: ClientContext, + sandbox_id: str, + paths: tuple[str, ...], + output_format: str | None, +) -> None: + """Delete directories from the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.files.delete_directories(list(paths)) + obj.output.print_rows( + [{"path": p, "status": "removed"} for p in paths], + columns=["path", "status"], + title="Removed Directories", + ) + finally: + sandbox.close() + + +# ---- search --------------------------------------------------------------- + +@file_group.command("search") +@click.argument("sandbox_id") +@click.argument("path") +@click.option("--pattern", "-p", required=True, help="Glob pattern to search for.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_search( + obj: ClientContext, + sandbox_id: str, + path: str, + pattern: str, + output_format: str | None, +) -> None: + """Search for files in the sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + from opensandbox.models.filesystem import SearchEntry + + sandbox = obj.connect_sandbox(sandbox_id) + try: + results = sandbox.files.search(SearchEntry(path=path, pattern=pattern)) + if not results: + if obj.output.fmt in ("json", "yaml"): + obj.output.print_models([], columns=[]) + else: + obj.output.info("No files found.") + return + if obj.output.fmt in ("json", "yaml"): + obj.output.print_models(results, columns=["path", "size", "mode", "owner", "modified_at"]) + else: + obj.output.print_models(results, columns=["path", "size", "owner"], title="Search Results") + finally: + sandbox.close() + + +# ---- info (stat) ---------------------------------------------------------- + +@file_group.command("info") +@click.argument("sandbox_id") +@click.argument("paths", nargs=-1, required=True) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_info( + obj: ClientContext, + sandbox_id: str, + paths: tuple[str, ...], + output_format: str | None, +) -> None: + """Get file/directory info.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + info_map = sandbox.files.get_file_info(list(paths)) + rows = [{"path": path, **entry.model_dump(mode="json")} for path, entry in info_map.items()] + obj.output.print_rows( + rows, + columns=["path", "size", "mode", "owner", "group", "created_at", "modified_at"], + title="Path Info", + ) + finally: + sandbox.close() + + +# ---- chmod ---------------------------------------------------------------- + +@file_group.command("chmod") +@click.argument("sandbox_id") +@click.argument("path") +@click.option("--mode", required=True, help="Permission mode (e.g. 0755).") +@click.option("--owner", default=None, help="File owner.") +@click.option("--group", default=None, help="File group.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_chmod( + obj: ClientContext, + sandbox_id: str, + path: str, + mode: str, + owner: str | None, + group: str | None, + output_format: str | None, +) -> None: + """Set file permissions.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + from opensandbox.models.filesystem import SetPermissionEntry + + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.files.set_permissions( + [ + SetPermissionEntry( + path=path, + mode=_parse_permission_mode(mode), + owner=owner, + group=group, + ) + ] + ) + obj.output.success(f"Permissions set: {path}") + finally: + sandbox.close() + + +# ---- replace -------------------------------------------------------------- + +@file_group.command("replace") +@click.argument("sandbox_id") +@click.argument("path") +@click.option("--old", required=True, help="Text to search for.") +@click.option("--new", required=True, help="Replacement text.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def file_replace( + obj: ClientContext, + sandbox_id: str, + path: str, + old: str, + new: str, + output_format: str | None, +) -> None: + """Replace content in a file.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + from opensandbox.models.filesystem import ContentReplaceEntry + + sandbox = obj.connect_sandbox(sandbox_id) + try: + sandbox.files.replace_contents( + [ContentReplaceEntry(path=path, old_content=old, new_content=new)] + ) + obj.output.success(f"Replaced in: {path}") + finally: + sandbox.close() diff --git a/cli/src/opensandbox_cli/commands/sandbox.py b/cli/src/opensandbox_cli/commands/sandbox.py new file mode 100644 index 0000000..4053261 --- /dev/null +++ b/cli/src/opensandbox_cli/commands/sandbox.py @@ -0,0 +1,594 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Sandbox lifecycle commands: create, list, get, kill, pause, resume, renew, endpoint, health, metrics.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from typing import Any + +import click +from opensandbox.models.sandboxes import ( + CredentialProxyConfig, + NetworkPolicy, + SandboxFilter, + SandboxImageAuth, + SandboxImageSpec, + SandboxMetrics, + SandboxState, + Volume, +) + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.utils import ( + DURATION, + KEY_VALUE, + handle_errors, + output_option, + parse_nullable_duration, + prepare_output, +) + + +@click.group("sandbox", invoke_without_command=True) +@click.pass_context +def sandbox_group(ctx: click.Context) -> None: + """📦 Manage sandbox lifecycle.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +# Alias: osb sb ... +sandbox_group.name = "sandbox" + +_SANDBOX_STATE_CANONICAL = { + state.lower(): state for state in SandboxState.values() +} + + +def _normalize_sandbox_states(states: tuple[str, ...]) -> list[str] | None: + """Normalize case-insensitive CLI state filters to SDK canonical values.""" + if not states: + return None + + normalized: list[str] = [] + for state in states: + canonical = _SANDBOX_STATE_CANONICAL.get(state.strip().lower()) + if canonical is None: + choices = ", ".join(sorted(SandboxState.values())) + raise click.ClickException( + f"Invalid sandbox state '{state}'. Valid values: {choices}" + ) + normalized.append(canonical) + return normalized + + +# ---- create --------------------------------------------------------------- + +@sandbox_group.command("create") +@click.option("--image", "-i", required=False, help="Container image (e.g. python:3.11). Defaults to config value if set.") +@click.option( + "--image-auth-username", + default=None, + help="Registry username for pulling a private image.", +) +@click.option( + "--image-auth-password", + default=None, + help="Registry password or token for pulling a private image.", +) +@click.option( + "--timeout", + "-t", + "timeout_raw", + default=None, + help="Sandbox lifetime (e.g. 10m, 1h), 'none' for manual cleanup, or omit to use defaults.timeout / SDK default TTL.", +) +@click.option("--env", "-e", "envs", multiple=True, type=KEY_VALUE, help="Environment variable (KEY=VALUE). Repeatable.") +@click.option("--metadata", "-m", "metadata_kv", multiple=True, type=KEY_VALUE, help="Metadata (KEY=VALUE). Repeatable.") +@click.option("--extension", "extensions_kv", multiple=True, type=KEY_VALUE, help="Extension parameter (KEY=VALUE). Repeatable.") +@click.option("--resource", "resources_kv", multiple=True, type=KEY_VALUE, help="Resource limit (e.g. cpu=1 memory=2Gi). Repeatable.") +@click.option( + "--entrypoint", + "entrypoint", + multiple=True, + help="Entrypoint argv item. Repeat to build the full entrypoint.", +) +@click.option("--network-policy-file", type=click.Path(exists=True), default=None, help="Network policy JSON file.") +@click.option( + "--credential-proxy", + is_flag=True, + default=False, + help="Enable Credential Vault transparent proxy support. Requires --network-policy-file.", +) +@click.option("--volumes-file", type=click.Path(exists=True), default=None, help="Volumes JSON file (list of volume objects).") +@click.option("--skip-health-check", is_flag=True, default=False, help="Skip waiting for sandbox readiness.") +@click.option("--ready-timeout", type=DURATION, default=None, help="Max wait time for sandbox readiness (e.g. 30s).") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_create( + obj: ClientContext, + image: str | None, + image_auth_username: str | None, + image_auth_password: str | None, + timeout_raw: str | None, + envs: tuple[tuple[str, str], ...], + metadata_kv: tuple[tuple[str, str], ...], + extensions_kv: tuple[tuple[str, str], ...], + resources_kv: tuple[tuple[str, str], ...], + entrypoint: tuple[str, ...], + network_policy_file: str | None, + credential_proxy: bool, + volumes_file: str | None, + skip_health_check: bool, + ready_timeout: timedelta | None, + output_format: str | None, +) -> None: + """Create a new sandbox.""" + from opensandbox.sync.sandbox import SandboxSync + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + + if image is None: + image = obj.resolved_config.get("default_image") + if not image: + raise click.ClickException( + "Sandbox image is required. Pass --image or set defaults.image in the CLI config." + ) + + if bool(image_auth_username) != bool(image_auth_password): + raise click.ClickException( + "Pass both --image-auth-username and --image-auth-password together." + ) + if credential_proxy and not network_policy_file: + raise click.ClickException( + "--credential-proxy requires --network-policy-file because Credential Vault injection needs egress policy." + ) + + timeout: timedelta | None + timeout_is_set = False + if timeout_raw is not None: + timeout = parse_nullable_duration(timeout_raw) + timeout_is_set = True + else: + timeout = None + + if timeout_raw is None: + default_timeout = obj.resolved_config.get("default_timeout") + if default_timeout: + timeout = parse_nullable_duration(default_timeout) + timeout_is_set = True + + image_spec: SandboxImageSpec | str = image + if image_auth_username and image_auth_password: + image_spec = SandboxImageSpec( + image=image, + auth=SandboxImageAuth( + username=image_auth_username, + password=image_auth_password, + ), + ) + + kwargs: dict = { + "connection_config": obj.connection_config, + "skip_health_check": skip_health_check, + } + if timeout_is_set: + kwargs["timeout"] = timeout + if ready_timeout is not None: + kwargs["ready_timeout"] = ready_timeout + if envs: + kwargs["env"] = dict(envs) + if metadata_kv: + kwargs["metadata"] = dict(metadata_kv) + if extensions_kv: + kwargs["extensions"] = dict(extensions_kv) + if resources_kv: + kwargs["resource"] = dict(resources_kv) + if entrypoint: + kwargs["entrypoint"] = list(entrypoint) + if network_policy_file: + with open(network_policy_file) as f: + kwargs["network_policy"] = NetworkPolicy(**json.load(f)) + if credential_proxy: + kwargs["credential_proxy"] = CredentialProxyConfig(enabled=True) + if volumes_file: + with open(volumes_file) as f: + raw_volumes = json.load(f) + if not isinstance(raw_volumes, list): + raise click.ClickException( + f"Volumes file must contain a JSON array, got {type(raw_volumes).__name__}." + ) + kwargs["volumes"] = [Volume(**item) for item in raw_volumes] + + with obj.output.spinner("Creating sandbox..."): + sandbox = SandboxSync.create(image_spec, **kwargs) + obj.output.success_panel( + { + "id": sandbox.id, + "image": image, + "status": "created", + "timeout": _describe_create_timeout(timeout_is_set, timeout), + }, + title="Sandbox Created", + ) + + +# ---- list ----------------------------------------------------------------- + +@sandbox_group.command("list") +@click.option("--state", "-s", "states", multiple=True, help="Filter by state (Pending, Running, Paused, ...). Repeatable.") +@click.option("--metadata", "-m", "metadata_kv", multiple=True, type=KEY_VALUE, help="Metadata filter (KEY=VALUE). Repeatable.") +@click.option("--page", type=click.IntRange(min=1), default=None, help="Page number (1-indexed).") +@click.option("--page-size", type=click.IntRange(min=1), default=None, help="Items per page.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_list( + obj: ClientContext, + states: tuple[str, ...], + metadata_kv: tuple[tuple[str, str], ...], + page: int | None, + page_size: int | None, + output_format: str | None, +) -> None: + """List sandboxes.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + mgr = obj.get_manager() + filt = SandboxFilter( + states=_normalize_sandbox_states(states), + metadata=dict(metadata_kv) if metadata_kv else None, + page=page, + page_size=page_size, + ) + with obj.output.spinner("Fetching sandboxes..."): + result = mgr.list_sandbox_infos(filt) + if not result.sandbox_infos: + if obj.output.fmt in ("json", "yaml"): + obj.output.print_dict( + { + "items": [], + "pagination": result.pagination.model_dump(mode="json"), + }, + title="Sandboxes", + ) + else: + obj.output.info("No sandboxes found.") + return + + raw_rows = [info.model_dump(mode="json") for info in result.sandbox_infos] + + # For machine-readable formats, preserve the original structure + if obj.output.fmt in ("json", "yaml"): + obj.output.print_dict( + { + "items": raw_rows, + "pagination": result.pagination.model_dump(mode="json"), + }, + title="Sandboxes", + ) + return + + # Flatten nested status/image objects for clean table display + rows = [] + for d in raw_rows: + flat = dict(d) + status_val = flat.get("status") + if isinstance(status_val, dict): + flat["status"] = status_val.get("state", str(status_val)) + image_val = flat.get("image") + if isinstance(image_val, dict): + flat["image"] = image_val.get("image", str(image_val)) + rows.append(flat) + + obj.output.print_rows( + rows, + columns=["id", "status", "image", "created_at", "expires_at"], + title="Sandboxes", + ) + + +# ---- get ------------------------------------------------------------------ + +@sandbox_group.command("get") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_get(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None: + """Get sandbox details.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + mgr = obj.get_manager() + info = mgr.get_sandbox_info(sandbox_id) + d = info.model_dump(mode="json") + + # For machine-readable formats, preserve the original structure + if obj.output.fmt in ("json", "yaml"): + obj.output.print_dict(d, title="Sandbox Info") + return + + # Flatten nested objects for clean table display + status_val = d.get("status") + if isinstance(status_val, dict): + d["status"] = status_val.get("state", str(status_val)) + if status_val.get("reason"): + d["status_reason"] = status_val["reason"] + if status_val.get("message"): + d["status_message"] = status_val["message"] + image_val = d.get("image") + if isinstance(image_val, dict): + d["image"] = image_val.get("image", str(image_val)) + obj.output.print_dict(d, title="Sandbox Info") + + +# ---- kill ----------------------------------------------------------------- + +@sandbox_group.command("kill") +@click.argument("sandbox_ids", nargs=-1, required=True) +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_kill( + obj: ClientContext, sandbox_ids: tuple[str, ...], output_format: str | None +) -> None: + """Terminate one or more sandboxes.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + mgr = obj.get_manager() + rows: list[dict[str, str]] = [] + for sid in sandbox_ids: + resolved = obj.resolve_sandbox_id(sid) + with obj.output.spinner(f"Killing sandbox {resolved}..."): + mgr.kill_sandbox(resolved) + rows.append({"sandbox_id": resolved, "status": "terminated"}) + obj.output.print_rows(rows, columns=["sandbox_id", "status"], title="Sandboxes") + + +# ---- pause ---------------------------------------------------------------- + +@sandbox_group.command("pause") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_pause(obj: ClientContext, sandbox_id: str, output_format: str | None) -> None: + """Pause a running sandbox.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + mgr = obj.get_manager() + with obj.output.spinner("Pausing sandbox..."): + mgr.pause_sandbox(sandbox_id) + obj.output.success(f"Sandbox paused: {sandbox_id}") + + +# ---- resume --------------------------------------------------------------- + +@sandbox_group.command("resume") +@click.argument("sandbox_id") +@click.option("--skip-health-check", is_flag=True, default=False, help="Skip waiting for sandbox readiness after resume.") +@click.option("--resume-timeout", type=DURATION, default=None, help="Max wait time for sandbox readiness after resume (e.g. 30s).") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_resume( + obj: ClientContext, + sandbox_id: str, + skip_health_check: bool, + resume_timeout: timedelta | None, + output_format: str | None, +) -> None: + """Resume a paused sandbox.""" + from opensandbox.sync.sandbox import SandboxSync + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + sandbox = None + try: + kwargs = { + "connection_config": obj.connection_config, + "skip_health_check": skip_health_check, + } + if resume_timeout is not None: + kwargs["resume_timeout"] = resume_timeout + + with obj.output.spinner("Resuming sandbox..."): + sandbox = SandboxSync.resume(sandbox_id, **kwargs) + obj.output.success(f"Sandbox resumed: {sandbox_id}") + finally: + if sandbox is not None: + sandbox.close() + + +# ---- renew ---------------------------------------------------------------- + +@sandbox_group.command("renew") +@click.argument("sandbox_id") +@click.option("--timeout", "-t", required=True, type=DURATION, help="New TTL duration (e.g. 30m, 2h).") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_renew( + obj: ClientContext, + sandbox_id: str, + timeout: timedelta, + output_format: str | None, +) -> None: + """Renew sandbox expiration.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox_id = obj.resolve_sandbox_id(sandbox_id) + mgr = obj.get_manager() + with obj.output.spinner("Renewing sandbox..."): + resp = mgr.renew_sandbox(sandbox_id, timeout) + obj.output.success_panel( + {"sandbox_id": sandbox_id, "expires_at": str(resp.expires_at)}, + title="Sandbox Renewed", + ) + + +# ---- endpoint ------------------------------------------------------------- + +@sandbox_group.command("endpoint") +@click.argument("sandbox_id") +@click.option("--port", "-p", required=True, type=click.IntRange(min=1, max=65535), help="Port number.") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_endpoint( + obj: ClientContext, sandbox_id: str, port: int, output_format: str | None +) -> None: + """Get the public endpoint for a sandbox port.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + ep = sandbox.get_endpoint(port) + obj.output.print_model(ep, title="Sandbox Endpoint") + finally: + sandbox.close() + + +# ---- health --------------------------------------------------------------- + +@sandbox_group.command("health") +@click.argument("sandbox_id") +@output_option("table", "json", "yaml") +@click.pass_obj +@handle_errors +def sandbox_health( + obj: ClientContext, sandbox_id: str, output_format: str | None +) -> None: + """Check sandbox health.""" + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + sandbox = obj.connect_sandbox(sandbox_id) + try: + healthy = sandbox.is_healthy() + if obj.output.fmt == "table": + if healthy: + obj.output.success(f"Sandbox {sandbox_id} is healthy") + else: + obj.output.error(f"Sandbox {sandbox_id} is unhealthy") + else: + obj.output.print_dict( + {"sandbox_id": sandbox_id, "healthy": healthy}, + title="Health Check", + ) + finally: + sandbox.close() + + +# ---- metrics -------------------------------------------------------------- + +@sandbox_group.command("metrics") +@click.argument("sandbox_id") +@click.option("--watch", is_flag=True, default=False, help="Stream metrics updates in real time.") +@output_option("table", "json", "yaml", "raw") +@click.pass_obj +@handle_errors +def sandbox_metrics( + obj: ClientContext, + sandbox_id: str, + watch: bool, + output_format: str | None, +) -> None: + """Get sandbox resource metrics.""" + fallback = "raw" if watch else "table" + prepare_output( + obj, output_format, allowed=("table", "json", "yaml", "raw"), fallback=fallback + ) + sandbox = obj.connect_sandbox(sandbox_id) + try: + if watch: + _watch_sandbox_metrics(obj, sandbox) + return + + m = sandbox.get_metrics() + obj.output.print_model(m, title="Sandbox Metrics") + finally: + sandbox.close() + + +def _watch_sandbox_metrics(obj: ClientContext, sandbox) -> None: # type: ignore[no-untyped-def] + """Stream sandbox metrics from the execd SSE endpoint.""" + client = getattr(sandbox.metrics, "_httpx_client", None) + if client is None: + raise click.ClickException("Streaming metrics are unavailable for this sandbox connection.") + + headers = {"Accept": "text/event-stream"} + + try: + with client.stream("GET", "/metrics/watch", headers=headers) as response: + response.raise_for_status() + for line in response.iter_lines(): + metric, warning = _parse_metric_stream_line(line) + if warning: + obj.output.warning(warning) + continue + if metric is None: + continue + _render_stream_metric(obj, metric) + except KeyboardInterrupt: + return + + +def _parse_metric_stream_line(line: str) -> tuple[SandboxMetrics | None, str | None]: + """Parse one line from the metrics SSE stream.""" + stripped = line.strip() + if not stripped or stripped.startswith((":","event:", "id:", "retry:")): + return None, None + + payload = stripped[5:].strip() if stripped.startswith("data:") else stripped + if not payload: + return None, None + + decoded: Any = json.loads(payload) + if isinstance(decoded, dict) and decoded.get("error"): + return None, f"Metrics stream error: {decoded['error']}" + return SandboxMetrics.model_validate(decoded), None + + +def _describe_create_timeout( + timeout_is_set: bool, timeout: timedelta | None +) -> str: + """Describe the sandbox timeout mode shown in create output.""" + if not timeout_is_set: + return "sdk-default" + if timeout is None: + return "manual-cleanup" + return str(timeout) + + +def _render_stream_metric(obj: ClientContext, metric: SandboxMetrics) -> None: + """Render one streaming metrics sample.""" + if obj.output.fmt in ("table", "raw"): + timestamp = datetime.fromtimestamp(metric.timestamp / 1000, tz=timezone.utc).isoformat() + click.echo( + " ".join( + [ + f"[{timestamp}]", + f"cpu={metric.cpu_used_percentage:.2f}%", + f"cores={metric.cpu_count:g}", + f"mem={metric.memory_used_in_mib:.2f}/{metric.memory_total_in_mib:.2f}MiB", + ] + ) + ) + return + + if obj.output.fmt == "json": + click.echo(json.dumps(metric.model_dump(mode="json"), default=str)) + return + + if obj.output.fmt == "yaml": + obj.output.print_model(metric) + return diff --git a/cli/src/opensandbox_cli/commands/skills.py b/cli/src/opensandbox_cli/commands/skills.py new file mode 100644 index 0000000..43d858c --- /dev/null +++ b/cli/src/opensandbox_cli/commands/skills.py @@ -0,0 +1,775 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Install built-in OpenSandbox AI skills/rules for coding tools.""" + +from __future__ import annotations + +import json +from dataclasses import asdict +from pathlib import Path +from typing import Literal, TypedDict, cast + +import click + +from opensandbox_cli.client import ClientContext +from opensandbox_cli.output import OutputFormatter +from opensandbox_cli.skill_registry import ( + BUILTIN_SKILLS, + DEFAULT_SKILL, + SkillSpec, + extract_section, + get_builtin_skill, + get_builtin_skill_source, + list_builtin_skills, + read_skill_markdown, + render_skill_for_target, + split_frontmatter, +) +from opensandbox_cli.utils import handle_errors, output_option, prepare_output + + +class CopyScopeConfig(TypedDict): + strategy: Literal["copy"] + dest_dir: Path + preserve_frontmatter: bool + file_suffix: str | None + dest_file_template: str | None + + +class AppendScopeConfig(TypedDict): + strategy: Literal["append"] + dest_file: Path + preserve_frontmatter: bool + + +TargetScopeConfig = CopyScopeConfig | AppendScopeConfig + + +class TargetConfig(TypedDict): + label: str + scopes: dict[str, TargetScopeConfig] + + +_TARGETS = cast(dict[str, TargetConfig], { + "claude": { + "label": "Claude Code", + "scopes": { + "project": { + "strategy": "copy", + "dest_dir": Path(".claude") / "skills", + "preserve_frontmatter": True, + }, + "global": { + "strategy": "copy", + "dest_dir": Path.home() / ".claude" / "skills", + "preserve_frontmatter": True, + } + }, + }, + "cursor": { + "label": "Cursor", + "scopes": { + "project": { + "strategy": "copy", + "dest_dir": Path(".cursor") / "rules", + "preserve_frontmatter": False, + "file_suffix": ".mdc", + }, + "global": { + "strategy": "copy", + "dest_dir": Path.home() / ".cursor" / "rules", + "preserve_frontmatter": False, + "file_suffix": ".mdc", + } + }, + }, + "codex": { + "label": "Codex", + "scopes": { + "project": { + "strategy": "copy", + "dest_dir": Path(".codex") / "skills", + "dest_file_template": "{slug}/SKILL.md", + "preserve_frontmatter": True, + }, + "global": { + "strategy": "copy", + "dest_dir": Path.home() / ".codex" / "skills", + "dest_file_template": "{slug}/SKILL.md", + "preserve_frontmatter": True, + }, + }, + }, + "copilot": { + "label": "GitHub Copilot", + "scopes": { + "project": { + "strategy": "append", + "dest_file": Path(".github") / "copilot-instructions.md", + "preserve_frontmatter": False, + }, + "global": { + "strategy": "append", + "dest_file": Path.home() / ".github" / "copilot-instructions.md", + "preserve_frontmatter": False, + } + }, + }, + "windsurf": { + "label": "Windsurf", + "scopes": { + "project": { + "strategy": "append", + "dest_file": Path(".windsurfrules"), + "preserve_frontmatter": False, + }, + "global": { + "strategy": "append", + "dest_file": Path.home() / ".windsurfrules", + "preserve_frontmatter": False, + } + }, + }, + "cline": { + "label": "Cline", + "scopes": { + "project": { + "strategy": "append", + "dest_file": Path(".clinerules"), + "preserve_frontmatter": False, + }, + "global": { + "strategy": "append", + "dest_file": Path.home() / ".clinerules", + "preserve_frontmatter": False, + } + }, + }, + "opencode": { + "label": "OpenCode", + "scopes": { + "project": { + "strategy": "copy", + "dest_dir": Path(".agents") / "skills", + "dest_file_template": "{slug}/SKILL.md", + "preserve_frontmatter": True, + }, + "global": { + "strategy": "copy", + "dest_dir": Path.home() / ".agents" / "skills", + "dest_file_template": "{slug}/SKILL.md", + "preserve_frontmatter": True, + }, + }, + }, +}) + +_ALL_TARGET_NAMES = list(_TARGETS.keys()) +_ALL_SKILL_NAMES = list(BUILTIN_SKILLS.keys()) +_ALL_SCOPE_NAMES = ["project", "global"] +_SKILL_AREAS = { + "sandbox-lifecycle": "Lifecycle", + "command-execution": "Execution", + "file-operations": "Files", + "network-egress": "Network", + "sandbox-troubleshooting": "Troubleshooting", +} + + +class InstallResult(TypedDict): + skill: str + target: str + target_label: str + scope: str + path: str + status: Literal["installed", "updated", "already_present"] + requires_restart: bool + + +class UninstallResult(TypedDict): + skill: str + target: str + target_label: str + scope: str + path: str + status: Literal["removed", "not_installed"] + requires_restart: bool + + +def _marker_begin(skill: SkillSpec) -> str: + return f"" + + +def _marker_end(skill: SkillSpec) -> str: + return f"" + + +def _get_scope_cfg(name: str, scope: str) -> TargetScopeConfig: + target_cfg = _TARGETS[name] + scopes = target_cfg["scopes"] + return scopes[scope] + + +def _target_layout_summary(name: str, scope: str) -> str: + cfg = _get_scope_cfg(name, scope) + if cfg["strategy"] == "append": + return f"aggregate into one instructions file at {cfg['dest_file']}" + + dest_dir = cfg["dest_dir"] + template = cfg.get("dest_file_template") + if template: + sample_path = dest_dir / template.format(slug="") + return f"install one file per skill under {sample_path}" + + suffix = cfg.get("file_suffix") or ".md" + sample_path = dest_dir / f"{suffix}" + return f"install one file per skill under {sample_path}" + + +def _target_destination(name: str, scope: str, skill: SkillSpec) -> Path: + cfg = _get_scope_cfg(name, scope) + if cfg["strategy"] == "copy": + dest_dir = cfg["dest_dir"] + template = cfg.get("dest_file_template") or "" + if template: + return dest_dir / template.format(slug=skill.slug) + suffix = cfg.get("file_suffix") or ".md" + return dest_dir / f"{skill.slug}{suffix}" + return cfg["dest_file"] + + +def _render_for_target(name: str, scope: str, skill: SkillSpec) -> str: + cfg = _get_scope_cfg(name, scope) + markdown = read_skill_markdown(skill) + preserve_frontmatter = bool(cfg.get("preserve_frontmatter", False)) + return render_skill_for_target( + skill, + markdown, + preserve_frontmatter=preserve_frontmatter, + ) + + +def _get_output_formatter() -> OutputFormatter | None: + ctx = click.get_current_context(silent=True) + obj = getattr(ctx, "obj", None) if ctx else None + output = getattr(obj, "output", None) if obj else None + return output if isinstance(output, OutputFormatter) else None + + +def _prepare_skills_output(output_format: str | None) -> None: + ctx = click.get_current_context(silent=True) + obj = getattr(ctx, "obj", None) if ctx else None + if isinstance(obj, ClientContext): + prepare_output(obj, output_format, allowed=("table", "json", "yaml"), fallback="table") + return + + existing = getattr(obj, "output", None) if obj is not None else None + if output_format is None and isinstance(existing, OutputFormatter): + return + + fmt = output_format or "table" + formatter = OutputFormatter(fmt, color=False) + if obj is not None: + obj.output = formatter + + +def _emit_output( + *, + table_renderer, + data: object, +) -> None: + output = _get_output_formatter() + if output is None or output.fmt == "table": + table_renderer() + return + + if output.fmt == "json": + click.echo(json.dumps(data, indent=2, default=str)) + return + + output._print_yaml(data) + + +def _remove_marked_block(existing: str, skill: SkillSpec) -> str: + begin = _marker_begin(skill) + end = _marker_end(skill) + if begin not in existing or end not in existing: + return existing + + start = existing.index(begin) + finish = existing.index(end) + len(end) + before = existing[:start].rstrip("\n") + after = existing[finish:].lstrip("\n") + + if before and after: + return before + "\n\n" + after + return before or after + + +def _is_installed(name: str, scope: str, skill: SkillSpec) -> bool: + dest = _target_destination(name, scope, skill) + if not dest.exists(): + return False + + cfg = _get_scope_cfg(name, scope) + if cfg["strategy"] == "copy": + return True + + content = dest.read_text(encoding="utf-8") + return _marker_begin(skill) in content and _marker_end(skill) in content + + +def _build_marked_block(skill: SkillSpec, content: str) -> str: + return ( + f"{_marker_begin(skill)}\n" + f"{content.strip()}\n" + f"{_marker_end(skill)}\n" + ) + + +def _install_copy(name: str, scope: str, skill: SkillSpec, content: str) -> tuple[str, Path]: + dest = _target_destination(name, scope, skill) + if not dest.exists(): + status = "installed" + else: + existing = dest.read_text(encoding="utf-8") + status = "already_present" if existing == content else "updated" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + return status, dest + + +def _install_append(name: str, scope: str, skill: SkillSpec, content: str) -> tuple[str, Path]: + dest = _target_destination(name, scope, skill) + dest.parent.mkdir(parents=True, exist_ok=True) + + existing = dest.read_text(encoding="utf-8") if dest.exists() else "" + cleaned = _remove_marked_block(existing, skill).rstrip("\n") + marked_block = _build_marked_block(skill, content) + new_content = f"{cleaned}\n\n{marked_block}" if cleaned else marked_block + if not existing: + status = "installed" + elif new_content == existing: + status = "already_present" + else: + status = "updated" if _is_installed(name, scope, skill) else "installed" + dest.write_text(new_content, encoding="utf-8") + return status, dest + + +def _install_target(name: str, scope: str, skill: SkillSpec) -> tuple[str, Path]: + content = _render_for_target(name, scope, skill) + cfg = _get_scope_cfg(name, scope) + if cfg["strategy"] == "copy": + return _install_copy(name, scope, skill, content) + return _install_append(name, scope, skill, content) + + +def _uninstall_target(name: str, scope: str, skill: SkillSpec) -> tuple[bool, Path]: + dest = _target_destination(name, scope, skill) + if not dest.exists(): + return False, dest + + cfg = _get_scope_cfg(name, scope) + if cfg["strategy"] == "copy": + dest.unlink() + if dest.parent.exists() and not any(dest.parent.iterdir()): + dest.parent.rmdir() + return True, dest + + existing = dest.read_text(encoding="utf-8") + cleaned = _remove_marked_block(existing, skill) + if cleaned == existing: + return False, dest + if cleaned.strip(): + dest.write_text(cleaned.rstrip("\n") + "\n", encoding="utf-8") + else: + dest.unlink() + return True, dest + + +def _resolve_skills(skill_name: str | None, install_all_builtins: bool) -> list[SkillSpec]: + if install_all_builtins: + return list_builtin_skills() + if not skill_name: + raise click.UsageError("A skill name is required unless --all-builtins is used.") + return [get_builtin_skill(skill_name)] + + +def _install_guidance_text() -> str: + return ( + "Install guidance:\n\n" + " Discover bundled skills first:\n" + " osb skills list\n" + " osb skills show \n\n" + " Install one skill for one tool:\n" + " osb skills install --target --scope \n\n" + " Install all bundled skills for one tool:\n" + " osb skills install --all-builtins --target --scope \n\n" + " Discover skills and targets:\n" + " osb skills list\n" + " osb skills show \n\n" + f" Available skills: {', '.join(_ALL_SKILL_NAMES)}\n" + f" Available targets: {', '.join(_ALL_TARGET_NAMES)}\n" + f" Available scopes: {', '.join(_ALL_SCOPE_NAMES)}" + ) + + +@click.group("skills", invoke_without_command=True) +@click.pass_context +def skills_group(ctx: click.Context) -> None: + """Manage bundled OpenSandbox skills for AI coding tools. + + Discover with `osb skills list`, inspect with `osb skills show `, + then install non-interactively with + `osb skills install --target codex --scope project`. + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@skills_group.command("install") +@click.argument( + "skill_name", + required=False, + type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False), +) +@click.option( + "--all-builtins", + is_flag=True, + default=False, + help="Install all bundled skills instead of a single skill.", +) +@click.option( + "--target", + "-t", + type=click.Choice(_ALL_TARGET_NAMES + ["all"], case_sensitive=False), + default=None, + help="Target AI tool to install the skill for.", +) +@click.option( + "--scope", + type=click.Choice(_ALL_SCOPE_NAMES, case_sensitive=False), + default=None, + help="Install scope for targets that support multiple locations.", +) +@click.option( + "--force", + "-f", + is_flag=True, + default=False, + help="Accepted for compatibility. Installs are already non-interactive and idempotent.", +) +@output_option("table", "json", "yaml") +@handle_errors +def skills_install( + skill_name: str | None, + all_builtins: bool, + target: str | None, + scope: str | None, + force: bool, + output_format: str | None, +) -> None: + """Install one or more bundled OpenSandbox skills. + + This command is non-interactive and idempotent. Re-running an install will + report `already_present` or `updated` instead of prompting. + """ + _prepare_skills_output(output_format) + if all_builtins and skill_name: + raise click.UsageError("Pass either a skill name or --all-builtins, not both.") + if target is None: + raise click.UsageError( + "Missing required option '--target'.\n\n" + _install_guidance_text() + ) + if scope is None: + raise click.UsageError( + "Missing required option '--scope'.\n\n" + _install_guidance_text() + ) + if not all_builtins and skill_name is None: + raise click.UsageError( + "A skill name is required unless --all-builtins is used.\n\n" + _install_guidance_text() + ) + _ = force + + skills = _resolve_skills(skill_name, all_builtins) + targets = _ALL_TARGET_NAMES if target == "all" else [target] + results: list[InstallResult] = [] + + for skill in skills: + for target_name in targets: + label = str(_TARGETS[target_name]["label"]) + status, installed_path = _install_target(target_name, scope, skill) + results.append( + { + "skill": skill.slug, + "target": target_name, + "target_label": label, + "scope": scope, + "path": str(installed_path), + "status": cast(Literal["installed", "updated", "already_present"], status), + "requires_restart": True, + } + ) + + def _render_table() -> None: + click.echo("Install plan:\n") + for target_name in targets: + label = str(_TARGETS[target_name]["label"]) + click.echo(f" {label} [{scope}]: {_target_layout_summary(target_name, scope)}") + click.echo() + + for result in results: + click.echo( + f" {result['status']:<15} " + f"{result['target_label']} [{result['scope']}]: " + f"{result['skill']} -> {result['path']}" + ) + + click.echo() + click.echo("Done! Restart your AI coding tool to pick up the updated skill set.") + + _emit_output( + table_renderer=_render_table, + data={ + "operations": results, + "requires_restart": True, + }, + ) + + +@skills_group.command("show") +@click.argument( + "skill_name", + type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False), +) +@output_option("table", "json", "yaml") +@handle_errors +def skills_show(skill_name: str, output_format: str | None) -> None: + """Show details for a bundled skill.""" + _prepare_skills_output(output_format) + skill = get_builtin_skill(skill_name) + markdown = read_skill_markdown(skill) + _, body = split_frontmatter(markdown) + when_to_use = extract_section(body, "When To Use") + quick_start = None + for heading in ( + "Triage Order", + "Golden Paths", + "Core Workflow", + "Command Map", + "Common Commands", + "Fast Path", + "Inspect Current Policy", + "Preferred Workflow", + ): + quick_start = extract_section(body, heading) + if quick_start: + break + + json_shapes = None + if "```json" in body: + start = body.find("```json") + end = body.find("```", start + 7) + if start != -1 and end != -1: + json_shapes = body[start + 7 : end].strip() + + payload = { + "skill": skill.slug, + "title": skill.title, + "area": _SKILL_AREAS.get(skill.slug, "General"), + "summary": skill.summary, + "trigger_hint": skill.trigger_hint, + "when_to_use": when_to_use, + "quick_start": quick_start, + "json_shapes": json_shapes, + "content": markdown.strip(), + } + + def _render_table() -> None: + click.echo(f"Skill: {skill.slug}") + click.echo(f"Title: {skill.title}") + click.echo(f"Area: {_SKILL_AREAS.get(skill.slug, 'General')}") + click.echo(f"Summary: {skill.summary}") + click.echo(f"Trigger: {skill.trigger_hint}") + click.echo() + + if when_to_use: + click.echo("When To Use:") + click.echo(when_to_use) + click.echo() + + if quick_start: + click.echo("Quick Start:") + click.echo(quick_start) + click.echo() + + for heading in ("Minimal Closed Loops", "Response Pattern", "Guidance"): + section = extract_section(body, heading) + if section: + click.echo(f"{heading}:") + click.echo(section) + click.echo() + + if json_shapes: + click.echo("JSON Shapes:") + click.echo(json_shapes) + click.echo() + + click.echo("Full Skill:") + click.echo(markdown.strip()) + + _emit_output(table_renderer=_render_table, data=payload) + + +@skills_group.command("list") +@output_option("table", "json", "yaml") +@handle_errors +def skills_list(output_format: str | None) -> None: + """List bundled skills, supported targets, and install status.""" + _prepare_skills_output(output_format) + skill_rows = [ + { + **asdict(skill), + "area": _SKILL_AREAS.get(skill.slug, "General"), + "source_path": str(get_builtin_skill_source(skill)), + } + for skill in list_builtin_skills() + ] + target_rows: list[dict[str, object]] = [] + for target_name, cfg in _TARGETS.items(): + label = str(cfg["label"]) + for scope_name in cfg["scopes"]: + installed_skills = [] + for skill in list_builtin_skills(): + dest = _target_destination(target_name, scope_name, skill) + status = "installed" if _is_installed(target_name, scope_name, skill) else "not installed" + installed_skills.append( + { + "skill": skill.slug, + "status": status, + "path": str(dest), + } + ) + target_rows.append( + { + "target": target_name, + "scope": scope_name, + "label": label, + "layout": _target_layout_summary(target_name, scope_name), + "skills": installed_skills, + } + ) + + def _render_table() -> None: + click.echo("Bundled skills:\n") + for skill in list_builtin_skills(): + area = _SKILL_AREAS.get(skill.slug, "General") + click.echo(f" {skill.slug:<24} [{area}] {skill.summary}") + click.echo(f" {'':<24} Trigger: {skill.trigger_hint}") + + click.echo("\nSupported targets:\n") + for target_row in target_rows: + click.echo( + f" {target_row['target']:<10} {target_row['scope']:<8} " + f"{target_row['label']:<18} {target_row['layout']}" + ) + for skill_row in cast(list[dict[str, str]], target_row["skills"]): + click.echo( + f" {'':<10} {'':<8} {'':<18} {skill_row['skill']:<24} " + f"{skill_row['status']:<13} ({skill_row['path']})" + ) + + _emit_output( + table_renderer=_render_table, + data={ + "skills": skill_rows, + "targets": target_rows, + }, + ) + + +@skills_group.command("uninstall") +@click.argument( + "skill_name", + required=False, + default=DEFAULT_SKILL, + type=click.Choice(_ALL_SKILL_NAMES, case_sensitive=False), +) +@click.option( + "--target", + "-t", + type=click.Choice(_ALL_TARGET_NAMES + ["all"], case_sensitive=False), + default=None, + help="Target AI tool to remove the skill from.", +) +@click.option( + "--scope", + type=click.Choice(_ALL_SCOPE_NAMES, case_sensitive=False), + default=None, + help="Install scope to remove from.", +) +@output_option("table", "json", "yaml") +@handle_errors +def skills_uninstall( + skill_name: str, + target: str | None, + scope: str | None, + output_format: str | None, +) -> None: + """Remove an installed OpenSandbox skill from one or more AI tools.""" + _prepare_skills_output(output_format) + if target is None: + raise click.UsageError( + "Missing required option '--target'.\n\n" + _install_guidance_text() + ) + if scope is None: + raise click.UsageError( + "Missing required option '--scope'.\n\n" + _install_guidance_text() + ) + skill = get_builtin_skill(skill_name) + targets = _ALL_TARGET_NAMES if target == "all" else [target] + results: list[UninstallResult] = [] + + for target_name in targets: + label = str(_TARGETS[target_name]["label"]) + removed, dest = _uninstall_target(target_name, scope, skill) + results.append( + { + "skill": skill.slug, + "target": target_name, + "target_label": label, + "scope": scope, + "path": str(dest), + "status": "removed" if removed else "not_installed", + "requires_restart": True, + } + ) + + def _render_table() -> None: + for result in results: + click.echo( + f" {result['status']:<15} " + f"{result['target_label']} [{result['scope']}]: " + f"{result['skill']} -> {result['path']}" + ) + + _emit_output( + table_renderer=_render_table, + data={ + "operations": results, + "requires_restart": True, + }, + ) diff --git a/cli/src/opensandbox_cli/config.py b/cli/src/opensandbox_cli/config.py new file mode 100644 index 0000000..3ac14b8 --- /dev/null +++ b/cli/src/opensandbox_cli/config.py @@ -0,0 +1,160 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""CLI configuration loading and management. + +Priority (highest to lowest): + 1. CLI flags + 2. Environment variables + 3. Config file (~/.opensandbox/config.toml) + 4. SDK defaults +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib # type: ignore[no-redef] + except ModuleNotFoundError: # pragma: no cover + tomllib = None # type: ignore[assignment] + + +DEFAULT_CONFIG_DIR = Path.home() / ".opensandbox" +DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml" + +DEFAULT_CONFIG_TEMPLATE = """\ +# OpenSandbox CLI configuration +# Priority: CLI flags > environment variables > this file > SDK defaults + +[connection] +# api_key = "your-api-key" +# domain = "localhost:8080" +# protocol = "http" +# request_timeout = 30 +# use_server_proxy = false + +[output] +# color = true + +[defaults] +# image = "python:3.11" +# timeout = "10m" # or "none" for manual cleanup mode +""" + + +def load_config_file(config_path: Path | None = None) -> dict[str, Any]: + """Load and parse the TOML config file. + + Returns an empty dict if the file doesn't exist or tomllib is unavailable. + """ + path = config_path or DEFAULT_CONFIG_PATH + if not path.exists(): + return {} + if tomllib is None: + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def resolve_config( + *, + cli_api_key: str | None = None, + cli_domain: str | None = None, + cli_protocol: str | None = None, + cli_timeout: int | None = None, + cli_use_server_proxy: bool | None = None, + config_path: Path | None = None, +) -> dict[str, Any]: + """Merge config from all sources and return a flat dict. + + Keys returned: + - api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool) + - default_image, default_timeout (str like "10m") + """ + file_cfg = load_config_file(config_path) + conn = file_cfg.get("connection", {}) + output_cfg = file_cfg.get("output", {}) + defaults = file_cfg.get("defaults", {}) + + return { + "api_key": cli_api_key + or os.getenv("OPEN_SANDBOX_API_KEY") + or conn.get("api_key"), + "domain": cli_domain + or os.getenv("OPEN_SANDBOX_DOMAIN") + or conn.get("domain"), + "protocol": cli_protocol + or os.getenv("OPEN_SANDBOX_PROTOCOL") + or conn.get("protocol") + or "http", + "request_timeout": cli_timeout + or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT")) + or conn.get("request_timeout") + or 30, + "use_server_proxy": _coalesce( + cli_use_server_proxy, + _bool_or_none(os.getenv("OPEN_SANDBOX_USE_SERVER_PROXY")), + conn.get("use_server_proxy"), + False, + ), + "color": output_cfg.get("color", True), + "default_image": defaults.get("image"), + "default_timeout": defaults.get("timeout"), + } + + +def init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path: + """Create a default config file. Returns the path written.""" + path = config_path or DEFAULT_CONFIG_PATH + if path.exists() and not force: + raise FileExistsError( + f"Config file already exists at {path}. Use --force to overwrite." + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(DEFAULT_CONFIG_TEMPLATE) + return path + + +def _int_or_none(value: str | None) -> int | None: + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +def _bool_or_none(value: str | None) -> bool | None: + if value is None: + return None + normalized = value.strip().lower() + if normalized in ("1", "true", "yes", "on"): + return True + if normalized in ("0", "false", "no", "off"): + return False + return None + + +def _coalesce(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None diff --git a/cli/src/opensandbox_cli/main.py b/cli/src/opensandbox_cli/main.py new file mode 100644 index 0000000..345a0a9 --- /dev/null +++ b/cli/src/opensandbox_cli/main.py @@ -0,0 +1,142 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Root Click group with global options.""" + +from __future__ import annotations + +from pathlib import Path + +import click +from rich.console import Console + +from opensandbox_cli import __version__ +from opensandbox_cli.client import ClientContext +from opensandbox_cli.commands.command import command_group +from opensandbox_cli.commands.config_cmd import config_group +from opensandbox_cli.commands.credential_vault import credential_vault_group +from opensandbox_cli.commands.devops import devops_group +from opensandbox_cli.commands.diagnostics import diagnostics_group +from opensandbox_cli.commands.egress import egress_group +from opensandbox_cli.commands.file import file_group +from opensandbox_cli.commands.sandbox import sandbox_group +from opensandbox_cli.commands.skills import skills_group +from opensandbox_cli.config import resolve_config + +# --------------------------------------------------------------------------- +# Banner +# --------------------------------------------------------------------------- + +BANNER = r"""[bold cyan] + ____ _____ _ _ + / __ \ / ____| | | | + | | | |_ __ ___ _ _| (___ __ _ _ __ __| | |__ _____ __ + | | | | '_ \ / _ \ '_ \___ \ / _` | '_ \ / _` | '_ \ / _ \ \/ / + | |__| | |_) | __/ | | |___) | (_| | | | | (_| | |_) | (_) > < + \____/| .__/ \___|_| |_|____/ \__,_|_| |_|\__,_|_.__/ \___/_/\_\ + | | + |_|[/] [dim]v{version}[/] +""" + + +class BannerGroup(click.Group): + """Custom Click group that shows a banner before help text.""" + + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + console = Console(stderr=False) + console.print(BANNER.format(version=__version__)) + super().format_help(ctx, formatter) + + +@click.group(cls=BannerGroup, context_settings={"help_option_names": ["-h", "--help"]}) +@click.option("--api-key", envvar="OPEN_SANDBOX_API_KEY", default=None, help="API key for authentication.") +@click.option("--domain", envvar="OPEN_SANDBOX_DOMAIN", default=None, help="API server domain (e.g. localhost:8080).") +@click.option("--protocol", type=click.Choice(["http", "https"]), default=None, help="Protocol (http/https).") +@click.option("--request-timeout", type=int, default=None, help="Request timeout in seconds.") +@click.option( + "--use-server-proxy/--no-use-server-proxy", + default=None, + help="Route execd and endpoint traffic through the sandbox server proxy.", +) +@click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.") +@click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.") +@click.option("--no-color", is_flag=True, default=False, help="Disable colored output.") +@click.version_option(version=__version__, prog_name="opensandbox") +@click.pass_context +def cli( + ctx: click.Context, + api_key: str | None, + domain: str | None, + protocol: str | None, + request_timeout: int | None, + use_server_proxy: bool | None, + config_path: Path | None, + verbose: bool, + no_color: bool, +) -> None: + """OpenSandbox CLI — manage sandboxes from your terminal.""" + if verbose: + import logging + + logging.basicConfig(level=logging.DEBUG) + + cli_overrides = { + "api_key": api_key, + "domain": domain, + "protocol": protocol, + "request_timeout": request_timeout, + "use_server_proxy": use_server_proxy, + } + + if ctx.invoked_subcommand == "config": + resolved = { + "api_key": api_key, + "domain": domain, + "protocol": protocol or "http", + "request_timeout": request_timeout or 30, + "use_server_proxy": use_server_proxy if use_server_proxy is not None else False, + "color": True, + "default_image": None, + "default_timeout": None, + } + else: + resolved = resolve_config( + cli_api_key=api_key, + cli_domain=domain, + cli_protocol=protocol, + cli_timeout=request_timeout, + cli_use_server_proxy=use_server_proxy, + config_path=config_path, + ) + resolved["color"] = not no_color and resolved.get("color", True) + + effective_config_path = config_path or Path.home() / ".opensandbox" / "config.toml" + ctx.obj = ClientContext( + resolved_config=resolved, + config_path=effective_config_path, + cli_overrides=cli_overrides, + ) + ctx.call_on_close(lambda: ctx.obj.close()) + + +# Register sub-command groups +cli.add_command(sandbox_group) +cli.add_command(command_group) +cli.add_command(file_group) +cli.add_command(egress_group) +cli.add_command(credential_vault_group) +cli.add_command(config_group) +cli.add_command(diagnostics_group) +cli.add_command(devops_group) +cli.add_command(skills_group) diff --git a/cli/src/opensandbox_cli/output.py b/cli/src/opensandbox_cli/output.py new file mode 100644 index 0000000..65cf3da --- /dev/null +++ b/cli/src/opensandbox_cli/output.py @@ -0,0 +1,363 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Output formatting: table (rich), JSON, YAML.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Generator, Sequence +from contextlib import contextmanager +from typing import Any + +import click + +try: + import yaml +except ImportError: # pragma: no cover + yaml = None # type: ignore[assignment] + +from pydantic import BaseModel +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.status import Status +from rich.table import Table +from rich.text import Text + +# --------------------------------------------------------------------------- +# Status badge styling (sandbox state → color + icon) +# --------------------------------------------------------------------------- + +_STATUS_STYLES: dict[str, tuple[str, str]] = { + # state → (rich style, icon) + "running": ("bold green", "●"), + "ready": ("bold green", "●"), + "healthy": ("bold green", "●"), + "pending": ("bold yellow", "◐"), + "creating": ("bold yellow", "◐"), + "starting": ("bold yellow", "◐"), + "paused": ("bold blue", "⏸"), + "stopped": ("dim", "○"), + "terminated": ("dim", "○"), + "killed": ("dim", "○"), + "error": ("bold red", "✗"), + "failed": ("bold red", "✗"), + "unhealthy": ("bold red", "✗"), + "created": ("bold cyan", "✦"), +} + +# Columns that contain status-like values +_STATUS_COLUMNS = {"status", "state", "healthy"} + +# Columns that should be rendered in a dimmer style (long IDs, timestamps) +_DIM_COLUMNS = {"created_at", "expires_at", "modified_at", "updated_at"} + +# Columns that are primary identifiers +_ID_COLUMNS = {"id", "sandbox_id", "execution_id", "context_id"} + + +def _style_value(col: str, value: str) -> Text: + """Apply contextual styling to a cell value.""" + lower = value.lower() + + if col in _STATUS_COLUMNS: + style, icon = _STATUS_STYLES.get(lower, ("", "")) + if style: + return Text(f"{icon} {value}", style=style) + + if col in _DIM_COLUMNS: + return Text(value, style="dim") + + if col in _ID_COLUMNS: + return Text(value, style="bold cyan") + + return Text(value) + + +class OutputFormatter: + """Renders data in table / json / yaml format.""" + + def __init__(self, fmt: str = "table", *, color: bool = True) -> None: + self.fmt = fmt + self.color = color + self.console = Console( + stderr=False, no_color=not color, force_terminal=None + ) + self._err_console = Console( + stderr=True, no_color=not color, force_terminal=None + ) + + # ------------------------------------------------------------------ + # Status messages with icons + # ------------------------------------------------------------------ + + def success(self, msg: str) -> None: + """Print a success message with ✅ icon.""" + if self.fmt == "json": + self._print_json({"status": "ok", "message": msg}) + return + if self.fmt == "yaml": + self._print_yaml({"status": "ok", "message": msg}) + return + if self.color: + self.console.print(f" [bold green]✅ {msg}[/]") + else: + click.echo(f"OK: {msg}") + + def info(self, msg: str) -> None: + """Print an info message with ℹ️ icon.""" + if self.fmt == "json": + self._print_json({"status": "info", "message": msg}) + return + if self.fmt == "yaml": + self._print_yaml({"status": "info", "message": msg}) + return + if self.color: + self.console.print(f" [bold blue]ℹ️ {msg}[/]") + else: + click.echo(f"INFO: {msg}") + + def warning(self, msg: str) -> None: + """Print a warning message with ⚠️ icon.""" + if self.fmt == "json": + self._print_json({"status": "warning", "message": msg}) + return + if self.fmt == "yaml": + self._print_yaml({"status": "warning", "message": msg}) + return + if self.color: + self._err_console.print(f" [bold yellow]⚠️ {msg}[/]") + else: + click.echo(f"WARN: {msg}", err=True) + + def error(self, msg: str) -> None: + """Print an error message with ❌ icon.""" + if self.fmt == "json": + self._print_json({"status": "error", "message": msg}) + return + if self.fmt == "yaml": + self._print_yaml({"status": "error", "message": msg}) + return + if self.color: + self._err_console.print(f" [bold red]❌ {msg}[/]") + else: + click.echo(f"ERROR: {msg}", err=True) + + def error_panel(self, msg: str, title: str = "Error") -> None: + """Print an error with a bold header and message.""" + if self.color: + self._err_console.print() + self._err_console.print(f" [bold red]{title}[/]") + self._err_console.print(f" [dim]{'─' * (len(title) + 2)}[/]") + for line in msg.splitlines(): + self._err_console.print(f" {line}") + self._err_console.print() + else: + click.echo(f"ERROR [{title}]: {msg}", err=True) + + # ------------------------------------------------------------------ + # Spinner for long-running operations + # ------------------------------------------------------------------ + + @contextmanager + def spinner(self, msg: str) -> Generator[Status, None, None]: + """Context manager that shows a spinner while work is in progress.""" + if self.color and self.fmt == "table": + with self._err_console.status(f"[bold cyan]⏳ {msg}[/]", spinner="dots") as status: + yield status + else: + # No spinner in non-color or non-table mode + yield None # type: ignore[arg-type] + + # ------------------------------------------------------------------ + # Panel output + # ------------------------------------------------------------------ + + def panel(self, content: str, *, title: str | None = None, style: str = "cyan") -> None: + """Print content inside a styled panel.""" + if self.color: + self.console.print(Panel( + content, + title=title, + title_align="left", + border_style=style, + box=box.ROUNDED, + padding=(0, 1), + )) + else: + if title: + click.echo(f"--- {title} ---") + click.echo(content) + + def success_panel(self, data: dict[str, Any], *, title: str = "Success") -> None: + """Print a success result with a header and indented key-value pairs.""" + if self.fmt != "table": + if self.fmt == "json": + self._print_json(data) + elif self.fmt == "yaml": + self._print_yaml(data) + return + + if self.color: + self.console.print() + self.console.print(f" [bold green]✓ {title}[/]") + self.console.print(f" [dim]{'─' * (len(title) + 2)}[/]") + for k, v in data.items(): + self.console.print(f" [bold]{k}:[/] [cyan]{v}[/]") + self.console.print() + else: + click.echo(f"--- {title} ---") + for k, v in data.items(): + click.echo(f" {k}: {v}") + + # ------------------------------------------------------------------ + # Public helpers + # ------------------------------------------------------------------ + + def print_model(self, model: BaseModel, title: str | None = None) -> None: + """Print a single Pydantic model as key-value panel or JSON/YAML.""" + data = _model_to_dict(model) + if self.fmt == "json": + self._print_json(data) + elif self.fmt == "yaml": + self._print_yaml(data) + else: + self._print_kv_table(data, title=title) + + def print_models( + self, + models: Sequence[BaseModel], + columns: list[str], + *, + title: str | None = None, + ) -> None: + """Print a list of Pydantic models as a table or JSON/YAML.""" + rows = [_model_to_dict(m) for m in models] + if self.fmt == "json": + self._print_json(rows) + elif self.fmt == "yaml": + self._print_yaml(rows) + else: + self._print_table(rows, columns, title=title) + + def print_rows( + self, + rows: list[dict[str, Any]], + columns: list[str], + *, + title: str | None = None, + ) -> None: + """Print pre-processed rows (list of dicts) as a table or JSON/YAML.""" + if self.fmt == "json": + self._print_json(rows) + elif self.fmt == "yaml": + self._print_yaml(rows) + else: + self._print_table(rows, columns, title=title) + + def print_dict(self, data: dict[str, Any], title: str | None = None) -> None: + """Print a flat dict.""" + if self.fmt == "json": + self._print_json(data) + elif self.fmt == "yaml": + self._print_yaml(data) + else: + self._print_kv_table(data, title=title) + + def print_text(self, text: str) -> None: + """Print raw text (ignores format).""" + click.echo(text) + + # ------------------------------------------------------------------ + # Internal renderers + # ------------------------------------------------------------------ + + def _print_json(self, data: Any) -> None: + if self.color: + self.console.print_json(json.dumps(data, default=str)) + else: + click.echo(json.dumps(data, indent=2, default=str)) + + def _print_yaml(self, data: Any) -> None: + if yaml is None: + click.secho( + "PyYAML is not installed. Use --output json instead.", fg="red", err=True + ) + sys.exit(1) + click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True).rstrip()) + + def _print_kv_table(self, data: dict[str, Any], *, title: str | None = None) -> None: + table = Table( + title=title, + show_header=True, + header_style="bold magenta", + title_style="bold cyan", + box=box.ROUNDED, + border_style="bright_black", + padding=(0, 1), + show_lines=True, + ) + table.add_column("Key", style="bold cyan", no_wrap=True) + table.add_column("Value") + for k, v in data.items(): + val_text = _style_value(k, str(v)) if v is not None else Text("-", style="dim") + table.add_row(str(k), val_text) + self.console.print(table) + + def _print_table( + self, + rows: list[dict[str, Any]], + columns: list[str], + *, + title: str | None = None, + ) -> None: + table = Table( + title=title, + show_header=True, + header_style="bold magenta", + title_style="bold cyan", + box=box.ROUNDED, + border_style="bright_black", + padding=(0, 1), + row_styles=["", "dim"], + ) + for col in columns: + style = "" + if col in _ID_COLUMNS: + style = "bold cyan" + elif col in _DIM_COLUMNS: + style = "dim" + table.add_column(col.upper(), style=style, no_wrap=(col in _ID_COLUMNS)) + + for row in rows: + cells: list[Text | str] = [] + for col in columns: + val = str(row.get(col, "-")) + if col in _STATUS_COLUMNS: + cells.append(_style_value(col, val)) + else: + cells.append(val) + table.add_row(*cells) + self.console.print(table) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + + +def _model_to_dict(model: BaseModel) -> dict[str, Any]: + return model.model_dump(mode="json") diff --git a/cli/src/opensandbox_cli/py.typed b/cli/src/opensandbox_cli/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/cli/src/opensandbox_cli/py.typed @@ -0,0 +1 @@ + diff --git a/cli/src/opensandbox_cli/skill_registry.py b/cli/src/opensandbox_cli/skill_registry.py new file mode 100644 index 0000000..e61a963 --- /dev/null +++ b/cli/src/opensandbox_cli/skill_registry.py @@ -0,0 +1,199 @@ +"""Built-in OpenSandbox skill metadata and rendering helpers.""" + +from __future__ import annotations + +import importlib.resources +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class SkillSpec: + """A bundled skill shipped with the CLI.""" + + slug: str + package_file: str + title: str + summary: str + trigger_hint: str + marker_id: str + + +BUILTIN_SKILLS: dict[str, SkillSpec] = { + "sandbox-troubleshooting": SkillSpec( + slug="sandbox-troubleshooting", + package_file="opensandbox-sandbox-troubleshooting.md", + title="OpenSandbox Sandbox Troubleshooting", + summary=( + "Triage failed or unhealthy sandboxes with state, health, stable " + "diagnostic logs/events, and concrete remediation steps." + ), + trigger_hint=( + "Use when the user reports sandbox startup failures, crashes, OOM, " + "image pull problems, pending sandboxes, or unreachable services." + ), + marker_id="opensandbox-sandbox-troubleshooting", + ), + "sandbox-lifecycle": SkillSpec( + slug="sandbox-lifecycle", + package_file="opensandbox-sandbox-lifecycle.md", + title="OpenSandbox Sandbox Lifecycle", + summary=( + "Create, inspect, renew, pause, resume, and terminate sandboxes " + "with the right defaults and follow-up checks." + ), + trigger_hint=( + "Use when the user wants to create or manage a sandbox and needs " + "the exact OpenSandbox CLI/API flow." + ), + marker_id="opensandbox-sandbox-lifecycle", + ), + "command-execution": SkillSpec( + slug="command-execution", + package_file="opensandbox-command-execution.md", + title="OpenSandbox Command Execution", + summary=( + "Run foreground and background commands, inspect status/logs, and " + "use persistent sessions inside a sandbox." + ), + trigger_hint=( + "Use when the user wants to execute commands in a sandbox, collect " + "logs, interrupt work, or reuse a persistent shell session." + ), + marker_id="opensandbox-command-execution", + ), + "file-operations": SkillSpec( + slug="file-operations", + package_file="opensandbox-file-operations.md", + title="OpenSandbox File Operations", + summary=( + "Read, write, upload, download, search, replace, and manage files " + "inside a sandbox without hand-wavy shell advice." + ), + trigger_hint=( + "Use when the user needs file or directory manipulation inside an " + "OpenSandbox sandbox." + ), + marker_id="opensandbox-file-operations", + ), + "network-egress": SkillSpec( + slug="network-egress", + package_file="opensandbox-network-egress.md", + title="OpenSandbox Network Egress", + summary=( + "Inspect and patch sandbox runtime egress rules when the user " + "needs to allow or deny outbound domains." + ), + trigger_hint=( + "Use when the user needs to view or modify outbound network access " + "for a sandbox, or debug domain allow and deny behavior." + ), + marker_id="opensandbox-network-egress", + ), + "credential-vault": SkillSpec( + slug="credential-vault", + package_file="opensandbox-credential-vault.md", + title="OpenSandbox Credential Vault", + summary=( + "Create, inspect, patch, and delete sandbox-local Credential Vault " + "state without exposing plaintext secrets in shell flags." + ), + trigger_hint=( + "Use when the user needs outbound credential injection for tools " + "inside a sandbox, or wants to manage Credential Vault credentials " + "and bindings at runtime." + ), + marker_id="opensandbox-credential-vault", + ), +} + +DEFAULT_SKILL = "sandbox-troubleshooting" + + +def list_builtin_skills() -> list[SkillSpec]: + """Return bundled skills in stable declaration order.""" + return list(BUILTIN_SKILLS.values()) + + +def get_builtin_skill(slug: str) -> SkillSpec: + """Return a bundled skill definition.""" + return BUILTIN_SKILLS[slug] + + +def get_builtin_skill_source(skill: SkillSpec) -> Path: + """Locate the bundled skill file shipped with the CLI package.""" + pkg = importlib.resources.files("opensandbox_cli") / "skills" / skill.package_file + if hasattr(pkg, "__fspath__"): + return Path(str(pkg)) + with importlib.resources.as_file(pkg) as resolved: + return Path(resolved) + + +def read_skill_markdown(skill: SkillSpec) -> str: + """Load bundled skill markdown.""" + return get_builtin_skill_source(skill).read_text(encoding="utf-8") + + +def split_frontmatter(markdown: str) -> tuple[str | None, str]: + """Split markdown into YAML frontmatter and body.""" + if not markdown.startswith("---\n"): + return None, markdown + + closing = markdown.find("\n---\n", 4) + if closing == -1: + return None, markdown + + frontmatter = markdown[4:closing] + body = markdown[closing + 5 :] + return frontmatter, body + + +def extract_section(body: str, heading: str) -> str | None: + """Extract a markdown section by exact heading text.""" + lines = body.splitlines() + capture = False + capture_level = 0 + captured: list[str] = [] + + for line in lines: + if line.startswith("## ") or line.startswith("### "): + if capture: + if capture_level == 2 and line.startswith("## "): + break + if capture_level == 3 and (line.startswith("## ") or line.startswith("### ")): + break + if line == f"## {heading}": + capture = True + capture_level = 2 + continue + if line == f"### {heading}": + capture = True + capture_level = 3 + continue + if capture: + captured.append(line) + + if not captured: + return None + + return "\n".join(captured).strip() or None + + +def render_skill_for_target( + skill: SkillSpec, + markdown: str, + *, + preserve_frontmatter: bool, +) -> str: + """Render skill content for the target tool.""" + frontmatter, body = split_frontmatter(markdown) + body = body.strip() + + if preserve_frontmatter or not frontmatter: + return markdown.strip() + "\n" + + return ( + f"# {skill.title}\n\n" + f"{skill.summary}\n\n" + f"{body}\n" + ) diff --git a/cli/src/opensandbox_cli/skills/opensandbox-command-execution.md b/cli/src/opensandbox_cli/skills/opensandbox-command-execution.md new file mode 100644 index 0000000..ab245af --- /dev/null +++ b/cli/src/opensandbox_cli/skills/opensandbox-command-execution.md @@ -0,0 +1,215 @@ +--- +name: command-execution +description: Use OpenSandbox command execution commands to run foreground or background processes, inspect tracked execution status and logs, interrupt work, and manage persistent shell sessions inside a sandbox. Trigger when users want to execute commands in a sandbox and need exact OpenSandbox CLI flows. +--- + +# OpenSandbox Command Execution + +Run commands with `osb command`. Use foreground streaming by default, and add `--background` when you need tracked execution. + +## When To Use + +- the user wants to run a one-off command in a sandbox +- the user needs a tracked background command with later status/log inspection +- the user wants to stop a running command +- the user wants a persistent shell session that keeps working directory or environment state across runs + +## Config Gate + +Run this first: + +```bash +osb config show -o json +``` + +If `domain` is missing, stop and set it before command execution: + +```bash +osb config set connection.domain -o json +osb config show -o json +``` + +If auth is required and `api_key` is missing, stop and set it before command execution: + +```bash +osb config set connection.api_key -o json +osb config show -o json +``` + +## Separator Rule + +Use `--` before the sandbox command payload. This is required when the payload contains flags like `-l`, `-c`, or `-m`. + +```bash +osb command run -o raw -- sh -lc 'echo ready' +osb command run -o raw -- python -m http.server +osb command session run -o raw -- sh -lc 'pwd' +``` + +## Execution Modes + +Treat these as three distinct execution paths: + +- `osb command run` without `--background` + Use for foreground one-shot commands when the result should stream directly to the terminal +- `osb command run --background` + Use when the user needs an execution ID, later status checks, or log retrieval +- `osb command session ...` + Use when shell state must persist across commands, such as exported variables or a working directory + +## Golden Paths + +Foreground one-shot command: + +```bash +osb command run -o raw -- python -c "print(1 + 1)" +``` + +Tracked background command: + +```bash +osb command run --background -o json -- sh -c "sleep 10; echo done" +osb command status -o json +osb command logs -o json +``` + +Persistent session: + +```bash +osb command session create --workdir /workspace -o json +osb command session run -o raw -- pwd +osb command session run -o raw -- export FOO=bar +osb command session run -o raw -- sh -c 'echo $FOO' +osb command session delete -o json +``` + +## Foreground Commands + +For simple one-off execution, use: + +```bash +osb command run -o raw -- +osb command run --workdir /workspace -o raw -- +osb command run --timeout 30s -o raw -- +``` + +Use foreground mode when the user wants immediate output and does not need a tracked execution ID. + +## Background Commands + +Use background mode when the user will need follow-up inspection: + +```bash +osb command run --background -o json -- +osb command run --background --workdir /workspace -o json -- +osb command run --background --timeout 5m -o json -- +``` + +Then inspect the tracked execution: + +```bash +osb command status -o json +osb command logs -o json +osb command logs --cursor 0 -o json +``` + +Use `status` for lifecycle state and exit information. Use `logs` for tracked background output. Do not suggest `command logs` for foreground commands that already streamed to the terminal. + +## Persistent Sessions + +Use sessions when commands must share shell state: + +```bash +osb command session create --workdir /workspace -o json +osb command session run -o raw -- pwd +osb command session run -o raw -- export FOO=bar +osb command session run -o raw -- sh -c 'echo $FOO' +osb command session run --workdir /var -o raw -- pwd +osb command session delete -o json +``` + +Rules: + +- `session create --workdir` sets the initial working directory for the session +- `session run --workdir` overrides the working directory for that single run only +- exported variables and shell state persist across `session run` calls in the same session +- delete the session when the user is done; do not leave idle sessions around + +## Interrupting Work + +Interrupt only tracked executions: + +```bash +osb command interrupt -o json +``` + +Only suggest interruption when the user explicitly wants to stop work or the process is clearly stuck. + +## Failure Semantics + +- foreground `osb command run` streams output directly and requires `-o raw` +- background `osb command run --background` returns tracked execution metadata and supports structured output +- `session run` also exits non-zero on execution error +- tracked background commands should be checked with `status` and `logs` +- if the command failure is caused by an unhealthy sandbox rather than the command itself, switch to `sandbox-troubleshooting` + +## Response Pattern + +Structure the answer as: + +1. exact command to run +2. which execution mode it uses +3. the next inspection or cleanup command if the workflow continues + +Keep command examples concrete and ready to paste. + +## Minimal Closed Loops + +Foreground command with timeout: + +```bash +osb command run --timeout 30s -o raw -- python -c "print(1 + 1)" +``` + +Tracked background execution: + +```bash +osb command run --background -o json -- sh -c "sleep 10; echo done" +osb command status -o json +osb command logs -o json +``` + +Background execution with interrupt: + +```bash +osb command run --background -o json -- sh -c "sleep 300" +osb command interrupt -o json +osb command status -o json +``` + +Persistent session with shared shell state: + +```bash +osb command session create --workdir /workspace -o json +osb command session run -o raw -- export FOO=bar +osb command session run -o raw -- sh -c 'echo $FOO' +osb command session delete -o json +``` + +Per-run working directory override inside a session: + +```bash +osb command session create --workdir /tmp -o json +osb command session run -o raw -- pwd +osb command session run --workdir /var -o raw -- pwd +osb command session delete -o json +``` + +## Best Practices + +- use `osb command run -o raw -- ...` for quick foreground commands +- use `--background` when the user will need execution tracking +- use sessions only when state persistence is actually needed +- use `--workdir` explicitly when directory context matters +- use `--timeout` when the command should not run indefinitely +- prefer `status` before guessing whether a background command is still running or already failed diff --git a/cli/src/opensandbox_cli/skills/opensandbox-credential-vault.md b/cli/src/opensandbox_cli/skills/opensandbox-credential-vault.md new file mode 100644 index 0000000..0c2612e --- /dev/null +++ b/cli/src/opensandbox_cli/skills/opensandbox-credential-vault.md @@ -0,0 +1,136 @@ +--- +name: credential-vault +description: Use OpenSandbox Credential Vault commands to manage sandbox-local outbound credential injection state. Trigger when users need to write credentials and bindings, inspect sanitized vault metadata, or update runtime credential injection rules for an existing sandbox. +--- + +# OpenSandbox Credential Vault + +Manage outbound credential injection with `osb credential-vault`. Treat vault +changes as a controlled runtime workflow: confirm the sandbox was created with +Credential Proxy, write secrets only through payload files or stdin, inspect +sanitized state, then verify actual outbound behavior from inside the sandbox. + +## When To Use + +- the user needs real credentials injected into outbound HTTP or HTTPS requests +- the user wants to create, inspect, patch, or delete Credential Vault state +- the user needs to add, replace, or remove runtime credentials or bindings +- the user wants to keep plaintext credentials out of sandbox env vars, files, + shell history, and command arguments + +## Prerequisites + +Credential Vault requires an egress sidecar with transparent MITM enabled. Create +the sandbox with both a network policy and Credential Proxy: + +```bash +osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json +``` + +If `credential-vault create`, `patch`, or `delete` returns a precondition error, +the sandbox was likely not created with Credential Proxy, the egress API auth +token is missing, mitmproxy is not ready, or insecure upstream TLS mode is set. + +## Payload Rules + +Use JSON or YAML payload files, or `--file -` for stdin. Do not put real secret +values in command-line flags. + +Create payload shape: + +```yaml +credentials: + - name: api-token + source: + value: secret-value +bindings: + - name: api + match: + schemes: [https] + hosts: [api.example.com] + paths: [/v1/*] + auth: + type: apiKey + name: x-api-key + credential: api-token +``` + +Patch payload shape: + +```yaml +expectedRevision: 1 +credentials: + add: + - name: runtime-token + source: + value: runtime-secret +bindings: + add: + - name: runtime-api + match: + schemes: [https] + hosts: [api.example.com] + auth: + type: bearer + credential: runtime-token +``` + +## Golden Path + +```bash +osb credential-vault create --file vault.yaml -o json +osb credential-vault get -o json +osb credential-vault credential list -o json +osb credential-vault binding list -o json +osb command run -o raw -- curl -I https://api.example.com +``` + +## Runtime Mutation + +Patch with optimistic concurrency when the current revision matters: + +```bash +osb credential-vault get -o json +osb credential-vault patch --file mutation.yaml -o json +osb credential-vault get -o json +``` + +Delete specific credentials or bindings by naming them in the patch payload: + +```yaml +expectedRevision: 2 +bindings: + delete: [runtime-api] +credentials: + delete: [runtime-token] +``` + +## Inspect Metadata + +Vault read APIs return sanitized metadata only. Plaintext credential values are +write-only and should not appear in command output. + +```bash +osb credential-vault credential get api-token -o json +osb credential-vault binding get api -o json +``` + +## Cleanup + +Delete all sandbox-local vault state when credential injection is no longer +needed: + +```bash +osb credential-vault delete -o json +``` + +## Response Pattern + +When helping a user: + +1. Confirm the active OpenSandbox config with `osb config show -o json`. +2. Confirm the sandbox was created with `--credential-proxy` and network policy. +3. Keep secret material in payload files or stdin, never in CLI flags. +4. Use `get` or `list` to inspect sanitized state. +5. Verify behavior with `osb command run ... -o raw -- curl ...` when injection + behavior matters. diff --git a/cli/src/opensandbox_cli/skills/opensandbox-file-operations.md b/cli/src/opensandbox_cli/skills/opensandbox-file-operations.md new file mode 100644 index 0000000..638f3c6 --- /dev/null +++ b/cli/src/opensandbox_cli/skills/opensandbox-file-operations.md @@ -0,0 +1,244 @@ +--- +name: file-operations +description: Use OpenSandbox file commands to read, write, upload, download, search, replace, move, delete, and inspect files or directories inside a sandbox. Trigger when users want exact sandbox file manipulation commands instead of generic shell guidance. +--- + +# OpenSandbox File Operations + +Manipulate sandbox files with `osb file` commands. Choose the operation mode first, then use the matching verification step. Do not mix sandbox-internal edits with host-to-sandbox transfer commands casually. + +## When To Use + +- the user wants to read or write a file inside a sandbox +- the user needs to upload a local file into a sandbox or download a sandbox file back to the host +- the user wants to search, replace, move, chmod, or inspect paths +- the user needs directory creation or cleanup + +## Config Gate + +Run this first: + +```bash +osb config show -o json +``` + +If `domain` is missing, stop and set it before file commands: + +```bash +osb config set connection.domain -o json +osb config show -o json +``` + +If auth is required and `api_key` is missing, stop and set it before file commands: + +```bash +osb config set connection.api_key -o json +osb config show -o json +``` + +## Operation Modes + +Treat these as distinct categories: + +- sandbox-only content operations + `cat`, `write`, `replace`, `mv`, `mkdir`, `rm`, `rmdir`, `info`, `chmod` +- host-to-sandbox transfer + `upload` +- sandbox-to-host transfer + `download` +- discovery before modification + `search`, then `info` + +If the path is uncertain, search first. If the file boundary crosses between host and sandbox, use `upload` or `download` instead of `write` or `cat`. + +## Golden Paths + +Write and verify inside the sandbox: + +```bash +osb file write /workspace/app.txt -c "hello" -o json +osb file cat /workspace/app.txt -o raw +``` + +Upload from host and verify in the sandbox: + +```bash +osb file upload ./local.txt /workspace/local.txt -o json +osb file cat /workspace/local.txt -o raw +``` + +Search before editing: + +```bash +osb file search /workspace --pattern "*.py" -o json +osb file info /workspace/main.py -o json +``` + +## Sandbox-Only File Edits + +Read and write: + +```bash +osb file cat /path/to/file -o raw +osb file write /path/to/file -c "hello" -o json +``` + +Use `write` with `-c/--content` when the new content is known directly. If the content should come from stdin, omit `-c` and pipe or paste the content into the command. + +Edit existing content: + +```bash +osb file replace /path/to/file --old old --new new -o json +osb file mv /old/path /new/path -o json +``` + +Prefer `replace` for small text substitutions and `mv` for rename/path changes. Do not rewrite a full file when a targeted replace is enough. + +Create directories: + +```bash +osb file mkdir /workspace/output -o json +osb file mkdir /workspace/a /workspace/b --mode 755 -o json +``` + +## Host <-> Sandbox Transfer + +Host to sandbox: + +```bash +osb file upload ./local.txt /remote/path/local.txt -o json +``` + +Sandbox to host: + +```bash +osb file download /remote/path/result.json ./result.json -o json +``` + +Rules: + +- use `upload` when the source file is on the host +- use `download` when the destination should be written to the host filesystem +- use `write` and `cat` only when the operation stays entirely inside the sandbox + +## Metadata and Permissions + +Inspect metadata: + +```bash +osb file info /path/to/file -o json +osb file info /path/one /path/two -o json +``` + +Search by pattern: + +```bash +osb file search /workspace --pattern "*.py" -o json +``` + +Set permissions: + +```bash +osb file chmod /path/to/script --mode 755 -o json +osb file chmod /path/to/file --mode 644 --owner root --group root -o json +``` + +Use `info` after `chmod` when the user needs to confirm mode, ownership, or timestamps changed as expected. + +## Destructive Operations + +Delete files or directories only after verifying the target path: + +```bash +osb file info /workspace/tmp.txt -o json +osb file rm /workspace/tmp.txt -o json +``` + +```bash +osb file search /workspace --pattern "old-*" -o json +osb file rmdir /workspace/old-dir -o json +``` + +Rules: + +- prefer `info` when the exact path is known +- prefer `search` when the path is uncertain +- do not suggest `rm` or `rmdir` until the target has been verified +- after `mv`, `rm`, or `rmdir`, verify the new state with `info` or `search` + +## Failure Semantics + +- `upload` and `download` have host filesystem side effects; treat them as cross-boundary operations +- `download` writes to the local path immediately, so be explicit about the destination +- permission or ownership failures are usually path/runtime permission issues, not a reason to switch away from `osb file` +- if multiple file commands fail unexpectedly, check sandbox health before assuming a file-command bug + +## Response Pattern + +Structure the answer as: + +1. exact `osb file` command +2. which operation mode it uses +3. the next verification command if the workflow continues + +Keep command examples concrete and ready to paste. + +## Minimal Closed Loops + +Write and verify: + +```bash +osb file write /workspace/app.txt -c "hello" -o json +osb file cat /workspace/app.txt -o raw +``` + +Upload and verify: + +```bash +osb file upload ./local.txt /workspace/local.txt -o json +osb file cat /workspace/local.txt -o raw +``` + +Replace and verify: + +```bash +osb file replace /workspace/app.txt --old hello --new world -o json +osb file cat /workspace/app.txt -o raw +``` + +Change permissions and inspect: + +```bash +osb file chmod /workspace/script.sh --mode 755 -o json +osb file info /workspace/script.sh -o json +``` + +Create a directory and inspect it: + +```bash +osb file mkdir /workspace/output -o json +osb file info /workspace/output -o json +``` + +Move a file and verify the new path: + +```bash +osb file mv /workspace/app.txt /workspace/archive/app.txt -o json +osb file info /workspace/archive/app.txt -o json +``` + +Delete and verify removal: + +```bash +osb file info /workspace/tmp.txt -o json +osb file rm /workspace/tmp.txt -o json +osb file search /workspace --pattern "tmp.txt" -o json +``` + +## Best Practices + +- prefer `search` before modification when the path is not certain +- prefer `info` before destructive actions +- prefer `replace` over full rewrites for small text changes +- prefer `upload` and `download` only for host boundary crossings +- prefer explicit verification after `mv`, `chmod`, `rm`, and `rmdir` diff --git a/cli/src/opensandbox_cli/skills/opensandbox-network-egress.md b/cli/src/opensandbox_cli/skills/opensandbox-network-egress.md new file mode 100644 index 0000000..ea5b483 --- /dev/null +++ b/cli/src/opensandbox_cli/skills/opensandbox-network-egress.md @@ -0,0 +1,179 @@ +--- +name: network-egress +description: Use OpenSandbox egress commands to inspect and patch runtime outbound network policy for a sandbox. Trigger when users want to allow or deny domains, confirm current egress rules, or debug outbound network access problems caused by policy. +--- + +# OpenSandbox Network Egress + +Manage outbound network access with `osb egress`. Treat runtime egress patching as a policy workflow, not just a one-line command. Always inspect current state, patch only what is needed, then verify both policy text and actual network behavior. + +## When To Use + +- the user wants to see current egress rules for a sandbox +- the user wants to allow or deny one or more outbound domains for an existing sandbox +- the user reports outbound access issues and runtime policy may be the cause +- the user wants an exact OpenSandbox command for runtime network policy changes + +## Configuration Resolution + +Before suggesting egress commands, resolve the active OpenSandbox connection configuration: + +```bash +osb config show -o json +``` + +Check the resolved values for: + +- `domain` +- `api_key` +- `protocol` + +`osb config show` redacts the API key. Use it to confirm the effective target, not to recover the credential itself. + +Do not assume the user configures OpenSandbox only through environment variables. Work from the resolved configuration that the CLI will actually use. + +Hard stop: + +```bash +osb config show -o json +``` + +If `domain` is missing, stop and set it before policy changes: + +```bash +osb config set connection.domain -o json +osb config show -o json +``` + +If auth is required and `api_key` is missing, stop and set it before policy changes: + +```bash +osb config set connection.api_key -o json +osb config show -o json +``` + +## Policy Model + +Runtime egress policy consists of: + +- `defaultAction` + The fallback action when no rule matches +- `egress` + An ordered list of allow or deny rules + +Important semantics: + +- if `defaultAction` is omitted, the policy model defaults to `deny` +- runtime patching uses merge semantics; it is not a full policy replacement workflow +- targets should be domain-based, such as `pypi.org` or `*.example.com` +- do not assume IP or CIDR targets are supported in this workflow + +Use `osb egress patch` for already-created sandboxes. Use `osb sandbox create --network-policy-file ...` when the user wants to define policy at sandbox creation time. + +## Golden Paths + +Inspect and patch: + +```bash +osb egress get -o json +osb egress patch --rule allow=pypi.org -o json +osb egress get -o json +``` + +Inspect, patch, and verify actual behavior: + +```bash +osb egress get -o json +osb egress patch --rule allow=www.github.com --rule deny=pypi.org -o json +osb egress get -o json +osb command run -o raw -- curl -I https://www.github.com +osb command run -o raw -- curl -I https://pypi.org +``` + +## Inspect Current Policy + +Start by reading the current runtime policy: + +```bash +osb egress get -o json +``` + +Use this before patching whenever the existing state matters. + +## Patch Runtime Rules + +Patch only the rules the user actually asked for: + +```bash +osb egress patch --rule allow=pypi.org -o json +osb egress patch --rule deny=internal.example.com -o json +osb egress patch --rule allow=*.example.com -o json +osb egress patch --rule allow=www.github.com --rule deny=pypi.org -o json +``` + +Rules: + +- keep patches narrow and auditable +- express changes as explicit `allow` or `deny` entries +- do not present patching as a full replacement of the policy object + +## Behavior Verification + +Do not stop at policy text if the user is debugging connectivity. Verify runtime behavior with actual commands: + +```bash +osb command run -o raw -- curl -I https://pypi.org +osb command run -o raw -- curl -I https://www.github.com +``` + +Use: + +- `egress get` to confirm the current rule set +- `osb command run ... -o raw -- curl ...` to verify whether outbound access is actually allowed or denied + +## Runtime Notes + +- use lifecycle create-time policy files when the sandbox has not been created yet +- use `osb egress patch` only for an already-running or already-created sandbox +- if network access is still wrong after a correct patch, continue with `sandbox-troubleshooting` instead of assuming the patch command failed silently + +## Response Pattern + +Structure the answer as: + +1. exact `osb egress` command +2. what policy change it applies +3. the verification command to run next + +Keep command examples concrete and ready to paste. + +## Minimal Closed Loops + +Allow one domain and verify: + +```bash +osb egress get -o json +osb egress patch --rule allow=pypi.org -o json +osb egress get -o json +osb command run -o raw -- curl -I https://pypi.org +``` + +Flip behavior between two domains: + +```bash +osb egress get -o json +osb egress patch --rule allow=www.github.com --rule deny=pypi.org -o json +osb egress get -o json +osb command run -o raw -- curl -I https://www.github.com +osb command run -o raw -- curl -I https://pypi.org +``` + +## Best Practices + +- resolve the active connection configuration before patching policy on a specific server +- prefer `osb config show` over checking isolated environment variables +- inspect before patching +- patch only the minimum required domains +- verify actual behavior, not just rule text +- prefer explicit domain targets over broad wildcards unless the user truly needs them +- prefer create-time policy files for initial sandbox provisioning and runtime patching for later adjustment diff --git a/cli/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md b/cli/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md new file mode 100644 index 0000000..69307d4 --- /dev/null +++ b/cli/src/opensandbox_cli/skills/opensandbox-sandbox-lifecycle.md @@ -0,0 +1,307 @@ +--- +name: sandbox-lifecycle +description: Use OpenSandbox CLI lifecycle commands to create, inspect, verify, renew, pause, resume, expose, and terminate sandboxes. Trigger when users want help provisioning a sandbox, choosing create flags, checking runtime state, retrieving endpoints, or safely cleaning up sandboxes. +--- + +# OpenSandbox Sandbox Lifecycle + +Use OpenSandbox lifecycle commands directly instead of giving generic container advice. Prefer a verified lifecycle flow over isolated commands. + +## When To Use + +- the user wants to create a sandbox for a task or workflow +- the user needs to inspect sandbox state or health +- the user wants to expose a service port through an OpenSandbox endpoint +- the user wants to renew, pause, resume, or terminate a sandbox +- the user is unsure which create flags or file-based inputs to use + +## Configuration Resolution + +Before giving lifecycle commands, resolve the active OpenSandbox connection configuration: + +```bash +osb config show -o json +``` + +Check the resolved values for: + +- `domain` +- `api_key` +- `protocol` +- `use_server_proxy` when endpoint routing matters + +`osb config show` redacts the API key. Use it to confirm which server and protocol the CLI will target, not to recover credentials. + +Do not focus only on environment variables. `osb config show` already reflects the effective configuration after applying CLI flags, environment variables, config file values, and defaults. + +Hard stop: + +```bash +osb config show -o json +``` + +If `domain` is missing, stop and set it before lifecycle commands: + +```bash +osb config set connection.domain -o json +osb config show -o json +``` + +If auth is required and `api_key` is missing, stop and set it before lifecycle commands: + +```bash +osb config set connection.api_key -o json +osb config show -o json +``` + +If the effective target is clear, then continue with lifecycle commands. + +## Preflight Checks + +Run these checks before lifecycle commands: + +```bash +osb --version +osb config show -o json +``` + +If `osb` is missing, try a project-local entrypoint: + +```bash +.venv/bin/osb --version +.venv/bin/osb config show -o json +``` + +Use the results to classify the situation: + +- CLI available +- config resolves +- target looks plausible for the current environment + +## Golden Path + +Use this as the default lifecycle flow unless the user asks for a narrower action: + +```bash +osb config show -o json +osb sandbox create --image python:3.12 --timeout 30m -o json +osb sandbox get -o json +osb sandbox health -o json +``` + +If the sandbox is intended to serve traffic on a known port, continue with: + +```bash +osb sandbox endpoint --port 8080 -o json +``` + +This sequence is safer than stopping at `sandbox get`, because `get` confirms object state while `health` confirms the sandbox is actually reachable through the execd health path. + +## Create Options + +Start with the narrowest create command that matches the request: + +```bash +osb sandbox create --image python:3.12 -o json +osb sandbox create --image node:20 --timeout 30m -o json +osb sandbox create --image my-registry.example.com/team/app:latest --image-auth-username alice --image-auth-password -o json +osb sandbox create --image python:3.12 --timeout none -o json +osb sandbox create --image python:3.12 --entrypoint python --entrypoint -m --entrypoint http.server -o json +osb sandbox create --image python:3.12 --extension storage.id=abc123 -o json +osb sandbox create --image python:3.12 --ready-timeout 90s -o json +osb sandbox create --image python:3.12 --network-policy-file network-policy.json -o json +osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json +osb sandbox create --image python:3.12 --volumes-file volumes.json -o json +``` + +Use these options deliberately: + +- `--image`: required unless the CLI already has `defaults.image` configured +- `--image-auth-username` and `--image-auth-password`: use together when the image is in a private registry +- `--timeout`: recommended for most temporary workloads so sandboxes do not linger indefinitely +- `--timeout none`: disable automatic expiration and switch the sandbox to manual cleanup mode +- omit `--timeout`: use `defaults.timeout` when configured; otherwise the request falls back to the SDK/server default TTL +- `--entrypoint`: repeat the flag once per argv item; do not use JSON or shell-wrapped command strings +- `--extension`: repeat for opaque extension key-value pairs that should be passed through as-is +- `--ready-timeout`: increase this when the image or workload needs more startup time +- `--skip-health-check`: use only when the user explicitly wants object creation without waiting for readiness; do not use it to mask startup problems +- `--credential-proxy`: enable Credential Vault transparent proxy support; requires `--network-policy-file` + +If the user does not specify an image, recommend one that matches the runtime they need instead of guessing silently. + +## JSON Shapes + +When recommending `--network-policy-file` or `--volumes-file`, always show the JSON shape instead of assuming the user knows it. + +Example `network-policy.json`: + +```json +{ + "defaultAction": "deny", + "egress": [ + { + "action": "allow", + "target": "pypi.org" + }, + { + "action": "allow", + "target": "files.pythonhosted.org" + } + ] +} +``` + +Example `volumes-host.json`: + +```json +[ + { + "name": "workspace-data", + "host": { + "path": "/tmp/opensandbox-data" + }, + "mountPath": "/workspace/data", + "readOnly": false + } +] +``` + +Example `volumes-pvc.json`: + +```json +[ + { + "name": "shared-models", + "pvc": { + "claimName": "shared-models-pvc" + }, + "mountPath": "/workspace/models", + "readOnly": true + } +] +``` + +Prefer `pvc` when the environment supports it and the user needs a more portable storage definition. Use `host` when the user explicitly needs a host-path bind mount and the server has been configured to allow that path. + +## Verification + +Use verification commands in this order: + +```bash +osb sandbox get -o json +osb sandbox health -o json +osb sandbox metrics -o json +osb sandbox metrics --watch -o raw +``` + +Use: + +- `sandbox get` to inspect the current lifecycle state and metadata +- `sandbox health` to confirm the sandbox is usable +- `sandbox metrics` for a point-in-time resource snapshot +- `sandbox metrics --watch` when the user wants live CPU and memory updates while diagnosing load or pressure + +If the user needs a public or routed port, verify it explicitly: + +```bash +osb sandbox endpoint --port 8080 -o json +``` + +## Lifecycle Actions + +Use these commands for ongoing lifecycle management: + +```bash +osb sandbox list -o json +osb sandbox list --state running --state paused -o json +osb sandbox renew --timeout 30m -o json +osb sandbox pause -o json +osb sandbox resume -o json +osb sandbox kill -o json +``` + +Rules: + +- use `sandbox list` for discovery or filtering, not single-sandbox verification +- `sandbox list --state` accepts known lifecycle states case-insensitively +- use `renew` before long-running work instead of waiting for expiry +- use `pause` only when the workload can tolerate suspension +- use `kill` when cleanup is the real goal; do not leave orphaned sandboxes behind + +## Runtime Notes + +- `renew` resets the expiration to approximately `now + timeout`; treat it as a fresh TTL, not a simple additive extension to the old timestamp +- `create --timeout none` means no automatic expiration; cleanup becomes an explicit `kill` responsibility +- `create` without `--timeout` does not mean manual cleanup; it uses `defaults.timeout` first and otherwise leaves TTL selection to the SDK/server default +- `pause` and `resume` may depend on the underlying runtime; if the runtime does not support them, avoid promising they will work +- host-path volumes depend on server-side allowed host path configuration +- if creation fails or the sandbox never becomes healthy, switch to `sandbox-troubleshooting` instead of adding more create flags blindly + +## Response Pattern + +Structure the answer as: + +1. exact command to run +2. what state change or verification result to expect +3. the next lifecycle command if the workflow continues + +Keep command examples concrete and ready to paste. + +## Minimal Closed Loops + +Create and verify readiness: + +```bash +osb sandbox create --image python:3.12 --timeout 30m -o json +osb sandbox get -o json +osb sandbox health -o json +``` + +Create a service sandbox and retrieve its endpoint: + +```bash +osb sandbox create --image python:3.12 --timeout 30m -o json +osb sandbox health -o json +osb sandbox endpoint --port 8080 -o json +``` + +Create with network policy: + +```bash +osb sandbox create --image python:3.12 --network-policy-file network-policy.json -o json +osb sandbox get -o json +osb sandbox health -o json +``` + +Renew before long work: + +```bash +osb sandbox renew --timeout 30m -o json +osb sandbox get -o json +``` + +Pause and confirm state: + +```bash +osb sandbox pause -o json +osb sandbox get -o json +``` + +Resume and verify health: + +```bash +osb sandbox resume -o json +osb sandbox health -o json +``` + +## Best Practices + +- resolve the active connection configuration before assuming which server the command will hit +- prefer `osb config show` over checking individual environment variables in isolation +- confirm whether the user wants to keep, override, or persist connection settings before changing them + +- Prefer explicit `--image` and `--timeout` when demonstrating commands +- Prefer `health` over assuming readiness from `create` output alone +- Prefer `endpoint` over telling the user to guess host/port mappings +- Prefer `pvc` over `host` when portability matters +- Prefer troubleshooting over `--skip-health-check` when startup is failing diff --git a/cli/src/opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md b/cli/src/opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md new file mode 100644 index 0000000..e3e5d1a --- /dev/null +++ b/cli/src/opensandbox_cli/skills/opensandbox-sandbox-troubleshooting.md @@ -0,0 +1,202 @@ +--- +name: sandbox-troubleshooting +description: Use OpenSandbox CLI state, health, and stable diagnostics logs/events to investigate failed, unhealthy, or unreachable sandboxes. Trigger when users report startup failures, crashes, OOM, image pull problems, pending sandboxes, network issues, or an unresponsive sandbox and want root cause plus next actions. +--- + +# OpenSandbox Sandbox Troubleshooting + +Investigate the reported sandbox before proposing a fix. Prefer evidence from OpenSandbox state, health checks, and stable diagnostics streams over speculation. + +## Inputs To Collect + +Capture these from the user request or surrounding context before running commands: + +- sandbox ID or an unambiguous short ID +- whether `osb` or `opensandbox` CLI is available locally +- any reported symptom: pending forever, crash, OOM, unreachable service, bad image, failed exec, etc. + +If the sandbox ID is missing, ask for it first. + +## Configuration Resolution + +Before troubleshooting a sandbox, resolve the active OpenSandbox connection configuration: + +```bash +osb config show -o json +``` + +Check the resolved values for: + +- `domain` +- `api_key` +- `protocol` +- `use_server_proxy` when endpoint routing or proxy behavior may matter + +`osb config show` redacts the API key. Use it to confirm the effective target server and protocol before troubleshooting. + +If `domain` is missing, stop and set it first: + +```bash +osb config set connection.domain -o json +osb config show -o json +``` + +If auth is required and `api_key` is missing, stop and set it first: + +```bash +osb config set connection.api_key -o json +osb config show -o json +``` + +Use raw HTTP only after domain, protocol, and API key expectations are explicit. + +## Operating Rules + +- start with the highest-signal commands first: sandbox state, sandbox health, then stable diagnostics events/logs +- use CLI commands when `osb` is available because they are shorter and usually already authenticated +- use HTTP only when the CLI is unavailable or the user is clearly working from raw API access +- distinguish observed facts from inference and quote the field, event, or log line that supports the diagnosis +- separate sandbox/runtime failures from workload/application failures before suggesting a fix +- do not paper over readiness problems with `--skip-health-check` +- end with a likely root cause and 1-3 concrete remediation steps + +## Triage Order + +Use this order by default: + +```bash +osb sandbox get -o json +osb sandbox health -o json +osb diagnostics events --scope lifecycle -o raw +osb diagnostics events --scope runtime -o raw +osb diagnostics logs --scope container -o raw +``` + +Then drill down only where the stable diagnostics point: + +```bash +osb diagnostics logs --scope lifecycle -o raw +osb diagnostics events --scope all -o raw +osb diagnostics logs --scope all -o raw +``` + +## Diagnostics Streams + +Important properties of the diagnostics commands: + +- `diagnostics events` and `diagnostics logs` are stable API-backed commands +- `--scope` is required for stable diagnostics; requests without scope use deprecated plain-text DevOps behavior +- if the server returns `DIAGNOSTICS_NOT_IMPLEMENTED`, state that stable diagnostics are unavailable on this server and stop diagnostics collection +- use known supported scopes first: `events:lifecycle`, `events:runtime`, `logs:lifecycle`, and `logs:container` +- `--scope all` is useful when the server supports aggregate diagnostics; if it is empty, retry concrete supported scopes +- other scopes such as `network` or `process` are server-defined and may be empty on some deployments +- `-o raw` prints inline diagnostic text directly, or a content URL when the server returns URL delivery +- `-o json` / `-o yaml` prints the CLI descriptor including `delivery`, `content_url`, `expires_at`, `truncated`, and `warnings` +- quote concrete lines from diagnostics output instead of summarizing vaguely + +Use: + +- `osb diagnostics events --scope lifecycle -o raw` for sandbox actions such as `CREATE`, `RENEW`, `DELETE`, `PAUSE`, `RESUME`, and `FORK` +- `osb diagnostics events --scope runtime -o raw` for scheduler and container events such as `Scheduled`, `Pulling`, `Pulled`, `Created`, `Started`, and `ContainerDied` +- `osb diagnostics logs --scope lifecycle -o raw` for manager server logs related to create, renew, delete, callbacks, request IDs, and server-side failures +- `osb diagnostics logs --scope container -o raw` for sandbox main-process stdout, including application errors, missing binaries, bad entrypoints, startup hangs, and health-check failures + +## Evidence Semantics + +- `sandbox get` shows control-plane state; it does not prove the workload is healthy +- `sandbox health` shows readiness or endpoint health; it can fail even when the sandbox is running +- lifecycle diagnostics explain OpenSandbox manager behavior; container logs explain the user workload +- runtime events are platform facts and usually outrank application logs for scheduling, image pull, restart, and kill reasons +- empty diagnostics do not prove there is no issue; the scope may be unsupported, expired, or outside retention +- `truncated: true` means the evidence is incomplete; lower confidence and mention the truncation +- always read `warnings`; they may explain missing, partial, unsupported, or expired diagnostic content + +## URL Delivery + +- with `delivery: inline`, use `content` as the diagnostic text +- with `delivery: url`, `-o raw` prints the diagnostic content URL; fetch it only if you need the diagnostic body +- `content_url` in structured CLI output is a diagnostic artifact URL, not a sandbox service endpoint +- check `expires_at`; container log URLs may expire quickly, so request diagnostics again if the URL is stale +- do not forward diagnostic URLs or lifecycle logs to unrelated people because they may contain sensitive troubleshooting data + +## Symptom To Command Mapping + +Use the first command that best matches the reported symptom: + +| Symptom | First command | What to confirm next | +| --- | --- | --- | +| pending forever or stuck creating | `osb diagnostics events --scope runtime -o raw` | image pull errors, scheduling failures, admission errors, then lifecycle logs | +| image pull failure | `osb diagnostics events --scope runtime -o raw` | image name, tag, registry auth | +| crash loop or repeated restarts | `osb diagnostics logs --scope container -o raw` | `osb diagnostics events --scope runtime -o raw` for restarts or kill signals | +| suspected OOM or exit code issue | `osb diagnostics events --scope runtime -o raw` | kill signals, restart events, resource pressure messages | +| endpoint unreachable or connection refused | `osb sandbox health -o json` | `osb sandbox endpoint --port -o json` and then `osb diagnostics logs --scope container -o raw` | +| outbound network access failure | `osb sandbox health -o json` | check service behavior, then switch to `network-egress` if the issue is egress policy related | + +## Diagnosis Playbooks + +### Image Pull Failure + +- first evidence: `events` shows `ImagePullBackOff`, `ErrImagePull`, or auth failures +- confirming evidence: sandbox stays `Pending` or never reaches healthy state +- likely cause: bad image reference or missing registry credentials +- next actions: verify image URI and tag, fix registry auth, recreate the sandbox + +### OOM Kill + +- first evidence: runtime events mention container killed due to out-of-memory or resource pressure +- confirming evidence: sandbox becomes unhealthy, restarts, or exits after memory-intensive work +- likely cause: memory limit too low for the workload +- next actions: increase memory, rerun the workload, compare peak workload memory with the configured limit + +### Crash Loop Or Bad Entrypoint + +- first evidence: `logs` show startup exceptions, missing binaries, or permission errors +- confirming evidence: runtime events show repeated restarts, failed starts, or container exit reasons +- likely cause: bad entrypoint, missing executable, or application crash on boot +- next actions: fix the command or image contents, correct file permissions, redeploy or recreate + +### Endpoint Or Service Unreachable + +- first evidence: sandbox is `Running` but client requests fail or connection is refused +- confirming evidence: `sandbox endpoint --port ` is missing, wrong, or points to a service that is not listening +- likely cause: wrong exposed port, service not bound, or server endpoint host misconfiguration +- next actions: verify the port, inspect service logs, and if the endpoint host is unreachable from the client environment check the server endpoint configuration + +## Minimal Closed Loops + +CLI-first troubleshooting: + +```bash +osb sandbox get -o json +osb sandbox health -o json +osb diagnostics events --scope lifecycle -o raw +osb diagnostics events --scope runtime -o raw +osb diagnostics logs --scope container -o raw +``` + +Crash-focused investigation: + +```bash +osb diagnostics logs --scope container -o raw +osb diagnostics events --scope runtime -o raw +``` + +Endpoint troubleshooting: + +```bash +osb sandbox get -o json +osb sandbox health -o json +osb sandbox endpoint --port -o json +osb diagnostics logs --scope container -o raw +``` + +## Response Format + +Structure the answer in this order: + +1. current state: what the sandbox is doing now +2. evidence: the command output that matters +3. root cause: the most likely diagnosis, stated as confidence not certainty when needed +4. next actions: specific fixes or follow-up checks + +Keep the conclusion compact. diff --git a/cli/src/opensandbox_cli/utils.py b/cli/src/opensandbox_cli/utils.py new file mode 100644 index 0000000..35ebad0 --- /dev/null +++ b/cli/src/opensandbox_cli/utils.py @@ -0,0 +1,212 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared CLI utilities: duration parsing, output selection, error handling, key-value parsing.""" + +from __future__ import annotations + +import functools +import re +import sys +from datetime import timedelta + +import click + +from opensandbox_cli.client import ClientContext + +# --------------------------------------------------------------------------- +# Duration parsing (e.g. "10m", "1h30m", "90s", "2h") +# --------------------------------------------------------------------------- + +_DURATION_RE = re.compile( + r"^(?:(?P\d+)h)?(?:(?P\d+)m)?(?:(?P\d+)s)?$" +) + + +def parse_duration(value: str) -> timedelta: + """Parse a human-friendly duration string into a ``timedelta``. + + Supported formats: ``10m``, ``1h30m``, ``90s``, ``2h``, ``1h30m45s``. + A plain integer is treated as seconds. + """ + value = value.strip() + if not value: + raise click.BadParameter("Duration cannot be empty") + + # Plain integer → seconds + if value.isdigit(): + return timedelta(seconds=int(value)) + + m = _DURATION_RE.match(value) + if not m or not m.group(0): + raise click.BadParameter( + f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s." + ) + + hours = int(m.group("hours") or 0) + minutes = int(m.group("minutes") or 0) + seconds = int(m.group("seconds") or 0) + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + + +def parse_nullable_duration(value: str) -> timedelta | None: + """Parse a duration string or the literal ``none``. + + ``none`` means no automatic timeout / manual cleanup mode. + """ + normalized = value.strip().lower() + if normalized == "none": + return None + return parse_duration(value) + + +class DurationType(click.ParamType): + """Click parameter type for duration strings.""" + + name = "duration" + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> timedelta: + if isinstance(value, timedelta): + return value + try: + return parse_duration(value) + except click.BadParameter: + self.fail( + f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.", + param, + ctx, + ) + + +DURATION = DurationType() + + +# --------------------------------------------------------------------------- +# Key=Value parsing (e.g. --env FOO=bar) +# --------------------------------------------------------------------------- + + +class KeyValueType(click.ParamType): + """Click parameter type that parses ``KEY=VALUE`` strings into a tuple.""" + + name = "KEY=VALUE" + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> tuple[str, str]: + if isinstance(value, tuple): + return value + if "=" not in value: + self.fail(f"Expected KEY=VALUE format, got '{value}'", param, ctx) + key, _, val = value.partition("=") + return (key, val) + + +KEY_VALUE = KeyValueType() + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + + +def output_option( + *choices: str, + default: str | None = None, + help_text: str | None = None, +): + """Attach a command-scoped output option.""" + option_help = help_text or f"Output format: {', '.join(choices)}." + return click.option( + "-o", + "--output", + "output_format", + type=click.Choice(list(choices), case_sensitive=False), + default=None if default is None else default, + show_default=default is not None, + help=option_help, + ) + + +def select_output_format( + obj: ClientContext, + requested: str | None, + *, + allowed: tuple[str, ...], + fallback: str, +) -> str: + """Resolve a command-scoped output format from explicit input, config, and fallback.""" + if requested: + if requested not in allowed: + allowed_list = ", ".join(allowed) + raise click.ClickException( + f"This command does not support `-o {requested}`. Allowed values: {allowed_list}." + ) + return requested + + return fallback + + +def prepare_output( + obj: ClientContext, + requested: str | None, + *, + allowed: tuple[str, ...], + fallback: str, +): + """Resolve and attach the formatter for the current command.""" + fmt = select_output_format(obj, requested, allowed=allowed, fallback=fallback) + return obj.make_output(fmt) + + +# --------------------------------------------------------------------------- +# Error handling decorator +# --------------------------------------------------------------------------- + + +def handle_errors(fn): # type: ignore[no-untyped-def] + """Decorator that catches SDK / HTTP exceptions and prints a friendly message.""" + + @functools.wraps(fn) + def wrapper(*args, **kwargs): # type: ignore[no-untyped-def] + try: + return fn(*args, **kwargs) + except click.exceptions.Exit: + raise + except click.ClickException: + raise + except Exception as exc: + # Import here to avoid circular imports at module level + from opensandbox.exceptions import SandboxException + + # Try to get the OutputFormatter from the Click context + ctx = click.get_current_context(silent=True) + obj = getattr(ctx, "obj", None) if ctx else None + output = getattr(obj, "output", None) if obj else None + + if output and hasattr(output, "error_panel"): + if isinstance(exc, SandboxException): + output.error_panel(str(exc), title="Sandbox Error") + else: + output.error_panel( + f"{str(exc)}\n\n[dim]Type: {type(exc).__qualname__}[/]", + title=type(exc).__name__, + ) + else: + click.secho(f"Error: {exc}", fg="red", err=True) + sys.exit(1) + + return wrapper diff --git a/cli/tests/__init__.py b/cli/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..58b150e --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,65 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared test fixtures.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from click.testing import CliRunner + +from opensandbox_cli.output import OutputFormatter + + +@pytest.fixture() +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture() +def mock_manager() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def mock_sandbox() -> MagicMock: + return MagicMock() + + +@pytest.fixture() +def mock_client_context(mock_manager: MagicMock, mock_sandbox: MagicMock) -> MagicMock: + """A mock ClientContext that avoids real SDK/HTTP calls.""" + ctx = MagicMock() + ctx.resolved_config = { + "api_key": "test-key", + "domain": "localhost:8080", + "protocol": "http", + "request_timeout": 30, + "color": False, + "default_image": None, + "default_timeout": None, + } + ctx.output = OutputFormatter("json", color=False) + def _make_output(fmt: str) -> OutputFormatter: + formatter = OutputFormatter(fmt, color=False) + ctx.output = formatter + return formatter + ctx.make_output.side_effect = _make_output + ctx.get_manager.return_value = mock_manager + ctx.connect_sandbox.return_value = mock_sandbox + ctx.connection_config = MagicMock() + ctx.close = MagicMock() + return ctx diff --git a/cli/tests/test_cli_help.py b/cli/tests/test_cli_help.py new file mode 100644 index 0000000..0f61ef9 --- /dev/null +++ b/cli/tests/test_cli_help.py @@ -0,0 +1,247 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests that all CLI commands register correctly and --help exits cleanly.""" + +from __future__ import annotations + +import pytest +from click.testing import CliRunner + +from opensandbox_cli.main import cli + + +@pytest.fixture() +def runner() -> CliRunner: + return CliRunner() + + +# --------------------------------------------------------------------------- +# Root +# --------------------------------------------------------------------------- + + +class TestRootCLI: + def test_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "OpenSandbox CLI" in result.output + assert "--request-timeout" in result.output + assert "--timeout" not in result.output + + def test_version(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "opensandbox" in result.output + + def test_root_lists_commands(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--help"]) + for cmd in ( + "sandbox", + "command", + "file", + "egress", + "credential-vault", + "config", + "diagnostics", + "devops", + "skills", + ): + assert cmd in result.output + + +# --------------------------------------------------------------------------- +# Sandbox sub-commands +# --------------------------------------------------------------------------- + + +class TestSandboxHelp: + def test_sandbox_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["sandbox", "--help"]) + assert result.exit_code == 0 + for subcmd in ("create", "list", "get", "kill", "pause", "resume", "renew", "endpoint", "health", "metrics"): + assert subcmd in result.output + + @pytest.mark.parametrize( + "subcmd", + ["create", "list", "get", "kill", "pause", "resume", "renew", "endpoint", "health", "metrics"], + ) + def test_sandbox_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: + result = runner.invoke(cli, ["sandbox", subcmd, "--help"]) + assert result.exit_code == 0 + assert subcmd in result.output.lower() or "usage" in result.output.lower() + + +# --------------------------------------------------------------------------- +# Command sub-commands +# --------------------------------------------------------------------------- + + +class TestCommandHelp: + def test_command_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["command", "--help"]) + assert result.exit_code == 0 + for subcmd in ("run", "status", "logs", "interrupt", "session"): + assert subcmd in result.output + + @pytest.mark.parametrize("subcmd", ["run", "status", "logs", "interrupt"]) + def test_command_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: + result = runner.invoke(cli, ["command", subcmd, "--help"]) + assert result.exit_code == 0 + + def test_command_session_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["command", "session", "--help"]) + assert result.exit_code == 0 + for subcmd in ("create", "run", "delete"): + assert subcmd in result.output + + +# --------------------------------------------------------------------------- +# File sub-commands +# --------------------------------------------------------------------------- + + +class TestFileHelp: + def test_file_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["file", "--help"]) + assert result.exit_code == 0 + for subcmd in ("cat", "write", "upload", "download", "rm", "mv", "mkdir", "rmdir", "search", "info", "chmod", "replace"): + assert subcmd in result.output + + @pytest.mark.parametrize( + "subcmd", + ["cat", "write", "upload", "download", "rm", "mv", "mkdir", "rmdir", "search", "info", "chmod", "replace"], + ) + def test_file_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: + result = runner.invoke(cli, ["file", subcmd, "--help"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# Egress sub-commands +# --------------------------------------------------------------------------- + + +class TestEgressHelp: + def test_egress_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["egress", "--help"]) + assert result.exit_code == 0 + for subcmd in ("get", "patch"): + assert subcmd in result.output + + +# --------------------------------------------------------------------------- +# Credential Vault sub-commands +# --------------------------------------------------------------------------- + + +class TestCredentialVaultHelp: + def test_credential_vault_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["credential-vault", "--help"]) + assert result.exit_code == 0 + for subcmd in ("create", "get", "patch", "delete", "credential", "binding"): + assert subcmd in result.output + + @pytest.mark.parametrize("subcmd", ["create", "get", "patch", "delete"]) + def test_credential_vault_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: + result = runner.invoke(cli, ["credential-vault", subcmd, "--help"]) + assert result.exit_code == 0 + + def test_credential_vault_credential_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["credential-vault", "credential", "--help"]) + assert result.exit_code == 0 + for subcmd in ("list", "get"): + assert subcmd in result.output + + def test_credential_vault_binding_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["credential-vault", "binding", "--help"]) + assert result.exit_code == 0 + for subcmd in ("list", "get"): + assert subcmd in result.output + + +# --------------------------------------------------------------------------- +# Config sub-commands +# --------------------------------------------------------------------------- + + +class TestConfigHelp: + def test_config_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["config", "--help"]) + assert result.exit_code == 0 + for subcmd in ("init", "show", "set"): + assert subcmd in result.output + + @pytest.mark.parametrize("subcmd", ["init", "show", "set"]) + def test_config_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: + result = runner.invoke(cli, ["config", subcmd, "--help"]) + assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# DevOps sub-commands +# --------------------------------------------------------------------------- + + +class TestDevopsHelp: + def test_devops_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["devops", "--help"]) + assert result.exit_code == 0 + for subcmd in ("logs", "inspect", "events", "summary"): + assert subcmd in result.output + + +# --------------------------------------------------------------------------- +# Diagnostics sub-commands +# --------------------------------------------------------------------------- + + +class TestDiagnosticsHelp: + def test_diagnostics_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["diagnostics", "--help"]) + assert result.exit_code == 0 + for subcmd in ("logs", "events"): + assert subcmd in result.output + + @pytest.mark.parametrize("subcmd", ["logs", "events"]) + def test_diagnostics_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: + result = runner.invoke(cli, ["diagnostics", subcmd, "--help"]) + assert result.exit_code == 0 + assert "--scope" in result.output + assert "content URL" in result.output + + def test_diagnostics_logs_help_describes_common_scopes(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["diagnostics", "logs", "--help"]) + assert result.exit_code == 0 + assert "lifecycle" in result.output + assert "container" in result.output + + def test_diagnostics_events_help_describes_common_scopes(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["diagnostics", "events", "--help"]) + assert result.exit_code == 0 + assert "lifecycle" in result.output + assert "runtime" in result.output + + +# --------------------------------------------------------------------------- +# Skills sub-commands +# --------------------------------------------------------------------------- + + +class TestSkillsHelp: + def test_skills_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["skills", "--help"]) + assert result.exit_code == 0 + for subcmd in ("install", "show", "list", "uninstall"): + assert subcmd in result.output diff --git a/cli/tests/test_commands.py b/cli/tests/test_commands.py new file mode 100644 index 0000000..462b561 --- /dev/null +++ b/cli/tests/test_commands.py @@ -0,0 +1,1430 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for CLI commands with mocked SDK calls. + +Strategy: patch ``opensandbox_cli.main.ClientContext`` and ``resolve_config`` +so the root ``cli`` callback creates our mock instead of a real SDK client. +""" + +from __future__ import annotations + +import json +from datetime import timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner +from opensandbox.models.diagnostics import DiagnosticContent +from opensandbox.models.sandboxes import SandboxImageSpec + +from opensandbox_cli.main import cli +from opensandbox_cli.output import OutputFormatter + + +@pytest.fixture() +def runner() -> CliRunner: + return CliRunner() + + +def _build_mock_client_context( + *, + manager: MagicMock | None = None, + sandbox: MagicMock | None = None, + output_format: str = "json", +) -> MagicMock: + ctx = MagicMock() + ctx.resolved_config = { + "api_key": "test-key", + "domain": "localhost:8080", + "protocol": "http", + "request_timeout": 30, + "use_server_proxy": False, + "color": False, + "default_image": None, + "default_timeout": None, + } + ctx.config_path = Path("/tmp/mock-config.toml") + ctx.cli_overrides = { + "api_key": None, + "domain": None, + "protocol": None, + "request_timeout": None, + "use_server_proxy": None, + } + ctx.output = OutputFormatter(output_format, color=False) + def _make_output(fmt: str) -> OutputFormatter: + formatter = OutputFormatter(fmt, color=False) + ctx.output = formatter + return formatter + ctx.make_output.side_effect = _make_output + ctx.get_manager.return_value = manager or MagicMock() + ctx.connect_sandbox.return_value = sandbox or MagicMock() + ctx.resolve_sandbox_id.side_effect = lambda prefix: prefix # passthrough + ctx.connection_config = MagicMock() + ctx.close = MagicMock() + return ctx + + +def _invoke( + runner: CliRunner, + args: list[str], + *, + manager: MagicMock | None = None, + sandbox: MagicMock | None = None, + output_format: str = "json", + input: str | None = None, +) -> object: + """Invoke CLI with mocked ClientContext.""" + mock_ctx = _build_mock_client_context( + manager=manager, sandbox=sandbox, output_format=output_format + ) + + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx): + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke(cli, args, input=input, catch_exceptions=False) + return result + + +# --------------------------------------------------------------------------- +# Config commands (no SDK mocking needed) +# --------------------------------------------------------------------------- + + +class TestConfigInit: + def test_init_creates_file(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + result = runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) + assert result.exit_code == 0 + assert "Config file created" in result.output + + def test_init_refuses_overwrite(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("existing") + result = runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) + assert "already exists" in result.output + + def test_init_force_overwrites(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("old") + result = runner.invoke(cli, ["--config", str(cfg_path), "config", "init", "--force"]) + assert result.exit_code == 0 + assert "Config file created" in result.output + + +class TestConfigShow: + def test_show_json_output(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--api-key", "test-key", "config", "show", "-o", "json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert "api_key" in data + assert data["api_key"] == "te****ey" + assert data["config_path"].endswith(".opensandbox/config.toml") + assert "config_file_exists" in data + + def test_show_table_output(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--api-key", "test-key", "config", "show"]) + assert result.exit_code == 0 + assert "api_key" in result.output + assert "test-key" not in result.output + assert "te****ey" in result.output + + def test_global_request_timeout_flag_overrides_resolved_config(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--request-timeout", "45", "config", "show", "-o", "json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["request_timeout"] == 45 + + +class TestConfigSet: + def test_set_updates_existing_field(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) + result = runner.invoke( + cli, + ["--config", str(cfg_path), "config", "set", "connection.domain", "new.host"], + ) + assert result.exit_code == 0 + assert "Set connection.domain = new.host" in result.output + + def test_set_rejects_flat_key(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("[connection]\n") + result = runner.invoke( + cli, + ["--config", str(cfg_path), "config", "set", "flat_key", "value"], + ) + assert result.exit_code != 0 + assert "section.field" in result.output + + def test_set_uses_root_config_path(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "custom.toml" + runner.invoke(cli, ["--config", str(cfg_path), "config", "init"]) + + result = runner.invoke( + cli, + ["--config", str(cfg_path), "config", "set", "connection.domain", "team.host"], + ) + + assert result.exit_code == 0 + assert 'domain = "team.host"' in cfg_path.read_text() + + def test_set_fails_when_config_file_is_missing(self, runner: CliRunner, tmp_path: Path) -> None: + cfg_path = tmp_path / "missing.toml" + result = runner.invoke( + cli, + ["--config", str(cfg_path), "config", "set", "connection.domain", "team.host"], + ) + assert result.exit_code != 0 + assert "Run 'osb config init' first." in result.output + + +# --------------------------------------------------------------------------- +# Sandbox commands +# --------------------------------------------------------------------------- + + +class TestSandboxList: + def test_list_invokes_manager(self, runner: CliRunner) -> None: + mock_mgr = MagicMock() + mock_result = MagicMock() + mock_result.sandbox_infos = [] + mock_mgr.list_sandbox_infos.return_value = mock_result + + result = _invoke(runner, ["sandbox", "list", "-o", "json"], manager=mock_mgr) + assert result.exit_code == 0 + mock_mgr.list_sandbox_infos.assert_called_once() + + def test_list_normalizes_state_filters_case_insensitively(self, runner: CliRunner) -> None: + mock_mgr = MagicMock() + mock_result = MagicMock() + mock_result.sandbox_infos = [] + mock_mgr.list_sandbox_infos.return_value = mock_result + + result = _invoke( + runner, + ["sandbox", "list", "-o", "json", "--state", "running", "--state", "PAUSED"], + manager=mock_mgr, + ) + + assert result.exit_code == 0 + filt = mock_mgr.list_sandbox_infos.call_args.args[0] + assert filt.states == ["Running", "Paused"] + + def test_list_rejects_unknown_state_filter(self, runner: CliRunner) -> None: + mock_mgr = MagicMock() + result = _invoke( + runner, + ["sandbox", "list", "--state", "runing"], + manager=mock_mgr, + ) + + assert result.exit_code != 0 + assert "Invalid sandbox state 'runing'" in result.output + mock_mgr.list_sandbox_infos.assert_not_called() + + def test_list_help_uses_one_indexed_pages(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["sandbox", "list", "--help"]) + assert result.exit_code == 0 + assert "Page number (1-indexed)." in result.output + + def test_list_rejects_page_zero(self, runner: CliRunner) -> None: + result = _invoke(runner, ["sandbox", "list", "--page", "0"]) + assert result.exit_code != 0 + assert "0 is not in the range x>=1" in result.output + + def test_list_passes_user_page_through_to_sdk(self, runner: CliRunner) -> None: + mock_mgr = MagicMock() + mock_result = MagicMock() + mock_result.sandbox_infos = [] + mock_result.pagination.model_dump.return_value = { + "page": 1, + "page_size": 20, + "total_items": 0, + "total_pages": 0, + "has_next_page": False, + } + mock_mgr.list_sandbox_infos.return_value = mock_result + + result = _invoke( + runner, + ["sandbox", "list", "--page", "1", "--page-size", "20", "-o", "json"], + manager=mock_mgr, + ) + + assert result.exit_code == 0 + filt = mock_mgr.list_sandbox_infos.call_args.args[0] + assert filt.page == 1 + data = json.loads(result.output) + assert data["pagination"]["page"] == 1 + assert data["items"] == [] + + +class TestSandboxCreate: + def test_create_uses_config_defaults(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + mock_ctx.resolved_config["default_image"] = "python:3.12" + mock_ctx.resolved_config["default_timeout"] = "15m" + + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke(cli, ["sandbox", "create", "-o", "json"], catch_exceptions=False) + + assert result.exit_code == 0 + mock_create.assert_called_once() + assert mock_create.call_args.args[0] == "python:3.12" + assert mock_create.call_args.kwargs["timeout"].total_seconds() == 900 + + def test_create_requires_image_when_no_default(self, runner: CliRunner) -> None: + result = _invoke(runner, ["sandbox", "create"]) + assert result.exit_code != 0 + assert "Sandbox image is required" in result.output + + def test_create_supports_timeout_none(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + ["sandbox", "create", "-o", "json", "--image", "python:3.12", "--timeout", "none"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert mock_create.call_args.kwargs["timeout"] is None + data = json.loads(result.output) + assert data["timeout"] == "manual-cleanup" + + def test_create_reports_sdk_default_timeout_when_unset(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + ["sandbox", "create", "-o", "json", "--image", "python:3.12"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "timeout" not in mock_create.call_args.kwargs + data = json.loads(result.output) + assert data["timeout"] == "sdk-default" + + def test_create_supports_default_timeout_none(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + mock_ctx.resolved_config["default_image"] = "python:3.12" + mock_ctx.resolved_config["default_timeout"] = "none" + + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke(cli, ["sandbox", "create", "-o", "json"], catch_exceptions=False) + + assert result.exit_code == 0 + assert mock_create.call_args.kwargs["timeout"] is None + + def test_create_passes_image_auth_to_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + [ + "sandbox", + "create", + "-o", + "json", + "--image", + "private.example.com/team/app:latest", + "--image-auth-username", + "alice", + "--image-auth-password", + "secret-token", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + image_arg = mock_create.call_args.args[0] + assert isinstance(image_arg, SandboxImageSpec) + assert image_arg.image == "private.example.com/team/app:latest" + assert image_arg.auth is not None + assert image_arg.auth.username == "alice" + assert image_arg.auth.password == "secret-token" + + def test_create_requires_both_image_auth_fields(self, runner: CliRunner) -> None: + result = _invoke( + runner, + [ + "sandbox", + "create", + "--image", + "private.example.com/team/app:latest", + "--image-auth-username", + "alice", + ], + ) + assert result.exit_code != 0 + assert "Pass both --image-auth-username and --image-auth-password together." in result.output + + def test_create_loads_volumes_from_file(self, runner: CliRunner, tmp_path: Path) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + volumes_path = tmp_path / "volumes.json" + volumes_path.write_text(json.dumps([ + { + "name": "workdir", + "host": {"path": "/tmp/workdir"}, + "mountPath": "/workspace", + } + ])) + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + ["sandbox", "create", "-o", "json", "--image", "python:3.12", "--volumes-file", str(volumes_path)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_create.assert_called_once() + volumes = mock_create.call_args.kwargs["volumes"] + assert len(volumes) == 1 + assert volumes[0].name == "workdir" + assert volumes[0].mount_path == "/workspace" + + def test_create_builds_entrypoint_argv_from_repeated_flags(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + [ + "sandbox", + "create", + "-o", + "json", + "--image", + "python:3.12", + "--entrypoint", + "python", + "--entrypoint", + "-m", + "--entrypoint", + "http.server", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_create.assert_called_once() + assert mock_create.call_args.kwargs["entrypoint"] == [ + "python", + "-m", + "http.server", + ] + + def test_create_passes_extensions_to_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + [ + "sandbox", + "create", + "-o", + "json", + "--image", + "python:3.12", + "--extension", + "storage.id=abc123", + "--extension", + "runtime.profile=fast", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_create.assert_called_once() + assert mock_create.call_args.kwargs["extensions"] == { + "storage.id": "abc123", + "runtime.profile": "fast", + } + + def test_create_passes_credential_proxy_to_sdk( + self, runner: CliRunner, tmp_path: Path + ) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + policy_path = tmp_path / "network-policy.json" + policy_path.write_text(json.dumps({ + "defaultAction": "deny", + "egress": [{"action": "allow", "target": "api.example.com"}], + })) + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.create", return_value=mock_sb) as mock_create: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + [ + "sandbox", + "create", + "-o", + "json", + "--image", + "python:3.12", + "--network-policy-file", + str(policy_path), + "--credential-proxy", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert mock_create.call_args.kwargs["network_policy"].egress[0].target == "api.example.com" + assert mock_create.call_args.kwargs["credential_proxy"].enabled is True + + def test_create_rejects_credential_proxy_without_network_policy( + self, runner: CliRunner + ) -> None: + result = _invoke( + runner, + [ + "sandbox", + "create", + "--image", + "python:3.12", + "--credential-proxy", + ], + ) + + assert result.exit_code != 0 + assert "--credential-proxy requires --network-policy-file" in result.output + + +class TestSandboxKill: + def test_kill_multiple(self, runner: CliRunner) -> None: + mock_mgr = MagicMock() + result = _invoke(runner, ["sandbox", "kill", "id1", "id2", "-o", "json"], manager=mock_mgr) + assert result.exit_code == 0 + assert mock_mgr.kill_sandbox.call_count == 2 + data = json.loads(result.output) + assert data == [ + {"sandbox_id": "id1", "status": "terminated"}, + {"sandbox_id": "id2", "status": "terminated"}, + ] + + +class TestSandboxPause: + def test_pause_calls_manager(self, runner: CliRunner) -> None: + mock_mgr = MagicMock() + result = _invoke(runner, ["sandbox", "pause", "sb-123"], manager=mock_mgr) + assert result.exit_code == 0 + mock_mgr.pause_sandbox.assert_called_once_with("sb-123") + assert "Sandbox paused: sb-123" in result.output + + +class TestSandboxResume: + def test_resume_uses_sdk_resume_and_waits_for_readiness(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.resume", return_value=mock_sb) as mock_resume: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke(cli, ["sandbox", "resume", "sb-123"], catch_exceptions=False) + + assert result.exit_code == 0 + mock_resume.assert_called_once_with( + "sb-123", + connection_config=mock_ctx.connection_config, + skip_health_check=False, + ) + mock_sb.close.assert_called_once() + assert "Sandbox resumed: sb-123" in result.output + + def test_resume_accepts_skip_health_check_and_timeout(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-123" + + mock_ctx = _build_mock_client_context(sandbox=mock_sb) + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ + patch("opensandbox.sync.sandbox.SandboxSync.resume", return_value=mock_sb) as mock_resume: + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + ["sandbox", "resume", "sb-123", "--skip-health-check", "--resume-timeout", "45s"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + mock_resume.assert_called_once_with( + "sb-123", + connection_config=mock_ctx.connection_config, + skip_health_check=True, + resume_timeout=timedelta(seconds=45), + ) + mock_sb.close.assert_called_once() + + +class TestSandboxMetrics: + def test_metrics_fetches_snapshot(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_metrics = MagicMock() + mock_metrics.model_dump.return_value = { + "cpu_count": 2, + "cpu_used_percentage": 12.5, + "memory_total_in_mib": 1024, + "memory_used_in_mib": 256, + "timestamp": 1710000000000, + } + mock_sb.get_metrics.return_value = mock_metrics + + result = _invoke(runner, ["sandbox", "metrics", "sb-1", "-o", "json"], sandbox=mock_sb) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["cpu_used_percentage"] == 12.5 + + def test_metrics_watch_streams_json_samples(self, runner: CliRunner) -> None: + class _FakeResponse: + def __init__(self) -> None: + self.lines = [ + 'data: {"cpu_count": 2, "cpu_used_percentage": 12.5, "memory_total_in_mib": 1024, "memory_used_in_mib": 256, "timestamp": 1710000000000}', + "", + 'data: {"cpu_count": 2, "cpu_used_percentage": 18.0, "memory_total_in_mib": 1024, "memory_used_in_mib": 300, "timestamp": 1710000001000}', + ] + + def __enter__(self) -> _FakeResponse: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def raise_for_status(self) -> None: + return None + + def iter_lines(self): + yield from self.lines + + mock_sb = MagicMock() + mock_sb.metrics._httpx_client.stream.return_value = _FakeResponse() + + result = _invoke( + runner, + ["sandbox", "metrics", "sb-1", "--watch", "-o", "json"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + lines = [json.loads(line) for line in result.output.strip().splitlines()] + assert len(lines) == 2 + assert lines[0]["cpu_used_percentage"] == 12.5 + assert lines[1]["memory_used_in_mib"] == 300 + + def test_metrics_watch_warns_and_continues_on_error_events(self, runner: CliRunner) -> None: + class _FakeResponse: + def __init__(self) -> None: + self.lines = [ + 'data: {"cpu_count": 2, "cpu_used_percentage": 12.5, "memory_total_in_mib": 1024, "memory_used_in_mib": 256, "timestamp": 1710000000000}', + 'data: {"error": "failed to get CPU percent"}', + 'data: {"cpu_count": 2, "cpu_used_percentage": 18.0, "memory_total_in_mib": 1024, "memory_used_in_mib": 300, "timestamp": 1710000001000}', + ] + + def __enter__(self) -> _FakeResponse: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def raise_for_status(self) -> None: + return None + + def iter_lines(self): + yield from self.lines + + mock_sb = MagicMock() + mock_sb.metrics._httpx_client.stream.return_value = _FakeResponse() + + result = _invoke( + runner, + ["sandbox", "metrics", "sb-1", "--watch", "-o", "json"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + decoder = json.JSONDecoder() + items: list[dict[str, object]] = [] + raw = result.output.strip() + index = 0 + while index < len(raw): + while index < len(raw) and raw[index].isspace(): + index += 1 + if index >= len(raw): + break + item, next_index = decoder.raw_decode(raw, index) + items.append(item) + index = next_index + + assert len(items) == 3 + assert items[0]["cpu_used_percentage"] == 12.5 + assert items[1]["status"] == "warning" + assert items[1]["message"] == "Metrics stream error: failed to get CPU percent" + assert items[2]["cpu_used_percentage"] == 18.0 + + +class TestSandboxEndpoint: + def test_endpoint_passes_valid_port_to_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_endpoint = MagicMock() + mock_endpoint.model_dump.return_value = {"endpoint": "http://example.test"} + mock_sb.get_endpoint.return_value = mock_endpoint + + result = _invoke( + runner, + ["sandbox", "endpoint", "sb-1", "--port", "8080", "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + mock_sb.get_endpoint.assert_called_once_with(8080) + + def test_endpoint_rejects_invalid_port(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, + ["sandbox", "endpoint", "sb-1", "--port", "70000"], + sandbox=mock_sb, + ) + + assert result.exit_code != 0 + assert "70000 is not in the range 1<=x<=65535" in result.output + mock_sb.get_endpoint.assert_not_called() + + +# --------------------------------------------------------------------------- +# File commands +# --------------------------------------------------------------------------- + + +class TestFileCat: + def test_cat_outputs_content(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.files.read_file.return_value = "hello world" + result = _invoke( + runner, + ["file", "cat", "sb-1", "/etc/hostname"], + sandbox=mock_sb, + output_format="table", + ) + assert result.exit_code == 0 + assert "hello world" in result.output + mock_sb.files.read_file.assert_called_once_with("/etc/hostname", encoding="utf-8") + + def test_cat_rejects_json_output(self, runner: CliRunner) -> None: + result = _invoke(runner, ["file", "cat", "sb-1", "/etc/hostname", "-o", "json"]) + assert result.exit_code != 0 + assert "Invalid value for '-o' / '--output'" in result.output + + +class TestFileWrite: + def test_write_with_content_flag(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, + ["file", "write", "sb-1", "/tmp/test.txt", "-c", "content here"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + assert "Written" in result.output + mock_sb.files.write_file.assert_called_once() + + def test_write_parses_permission_mode(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, + ["file", "write", "sb-1", "/tmp/test.txt", "-c", "content here", "--mode", "644"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + mock_sb.files.write_file.assert_called_once_with( + "/tmp/test.txt", "content here", encoding="utf-8", mode=644 + ) + + +class TestFileTransfer: + def test_upload_streams_file_object(self, runner: CliRunner, tmp_path: Path) -> None: + mock_sb = MagicMock() + local_path = tmp_path / "upload.bin" + local_path.write_bytes(b"hello") + + result = _invoke( + runner, + ["file", "upload", "sb-1", str(local_path), "/tmp/upload.bin"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + uploaded = mock_sb.files.write_file.call_args.args[1] + assert hasattr(uploaded, "read") + assert not isinstance(uploaded, bytes) + + def test_download_streams_chunks_to_disk(self, runner: CliRunner, tmp_path: Path) -> None: + mock_sb = MagicMock() + mock_sb.files.read_bytes_stream.return_value = iter([b"hel", b"lo"]) + local_path = tmp_path / "nested" / "download.txt" + + result = _invoke( + runner, + ["file", "download", "sb-1", "/tmp/download.txt", str(local_path)], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + assert local_path.read_bytes() == b"hello" + mock_sb.files.read_bytes_stream.assert_called_once_with("/tmp/download.txt") + + +class TestFileRm: + def test_rm_deletes_files(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, ["file", "rm", "sb-1", "/tmp/a", "/tmp/b", "-o", "json"], sandbox=mock_sb + ) + assert result.exit_code == 0 + mock_sb.files.delete_files.assert_called_once_with(["/tmp/a", "/tmp/b"]) + data = json.loads(result.output) + assert data == [ + {"path": "/tmp/a", "status": "deleted"}, + {"path": "/tmp/b", "status": "deleted"}, + ] + + +class TestFileMv: + def test_mv_moves_file(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, ["file", "mv", "sb-1", "/tmp/old", "/tmp/new"], sandbox=mock_sb + ) + assert result.exit_code == 0 + assert "Moved: /tmp/old" in result.output and "/tmp/new" in result.output + + +class TestFileMkdir: + def test_mkdir_creates_dirs(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, ["file", "mkdir", "sb-1", "/tmp/dir1", "/tmp/dir2", "-o", "json"], sandbox=mock_sb + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == [ + {"path": "/tmp/dir1", "status": "created"}, + {"path": "/tmp/dir2", "status": "created"}, + ] + + def test_mkdir_parses_octal_mode(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, + ["file", "mkdir", "sb-1", "/tmp/dir1", "--mode", "755"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + entry = mock_sb.files.create_directories.call_args.args[0][0] + assert entry.mode == 755 + + +class TestFileRmdir: + def test_rmdir_removes_dirs(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, ["file", "rmdir", "sb-1", "/workspace/old", "-o", "json"], sandbox=mock_sb + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data == [{"path": "/workspace/old", "status": "removed"}] + + +class TestFileInfo: + def test_info_returns_one_aggregated_document(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + entry = MagicMock() + entry.model_dump.return_value = { + "mode": 644, + "owner": "root", + "group": "root", + "size": 12, + "created_at": "2026-01-01T00:00:00Z", + "modified_at": "2026-01-02T00:00:00Z", + } + mock_sb.files.get_file_info.return_value = { + "/tmp/a": entry, + "/tmp/b": entry, + } + + result = _invoke( + runner, + ["file", "info", "sb-1", "/tmp/a", "/tmp/b", "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert [item["path"] for item in data] == ["/tmp/a", "/tmp/b"] + + +class TestFileChmod: + def test_chmod_parses_permission_mode(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, + ["file", "chmod", "sb-1", "/tmp/test.txt", "--mode", "755"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + entry = mock_sb.files.set_permissions.call_args.args[0][0] + assert entry.mode == 755 + + +class TestCommandSeparators: + def test_command_run_supports_shell_payload_after_separator(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + execution = MagicMock() + execution.error = None + mock_sb.commands.run.return_value = execution + + result = _invoke( + runner, + ["command", "run", "sb-1", "--", "sh", "-lc", "echo ready"], + sandbox=mock_sb, + output_format="raw", + ) + + assert result.exit_code == 0 + mock_sb.commands.run.assert_called_once() + assert mock_sb.commands.run.call_args.args[0] == "sh -lc 'echo ready'" + + def test_command_run_help_mentions_separator_rule(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["command", "run", "--help"]) + assert result.exit_code == 0 + assert "Separator rule: use `--` before the sandbox command payload." in result.output + + def test_session_run_help_mentions_separator_rule(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["command", "session", "run", "--help"]) + assert result.exit_code == 0 + assert "Separator rule: use `--` before the sandbox command payload." in result.output + + +# --------------------------------------------------------------------------- +# Egress commands +# --------------------------------------------------------------------------- + + +class TestEgressCommands: + def test_get_prints_policy(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_policy = MagicMock() + mock_policy.model_dump.return_value = { + "defaultAction": "deny", + "egress": [{"action": "allow", "target": "pypi.org"}], + } + mock_sb.get_egress_policy.return_value = mock_policy + + result = _invoke(runner, ["egress", "get", "sb-1", "-o", "json"], sandbox=mock_sb) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["defaultAction"] == "deny" + + def test_patch_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-1" + result = _invoke( + runner, + ["egress", "patch", "sb-1", "--rule", "allow=pypi.org", "--rule", "deny=bad.example.com", "-o", "json"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + mock_sb.patch_egress_rules.assert_called_once() + rules = mock_sb.patch_egress_rules.call_args.args[0] + assert len(rules) == 2 + assert rules[0].action == "allow" + assert rules[0].target == "pypi.org" + assert rules[1].action == "deny" + assert rules[1].target == "bad.example.com" + + +# --------------------------------------------------------------------------- +# Credential Vault commands +# --------------------------------------------------------------------------- + + +class TestCredentialVaultCommands: + def test_create_reads_payload_file_and_calls_sdk( + self, runner: CliRunner, tmp_path: Path + ) -> None: + mock_sb = MagicMock() + mock_state = MagicMock() + mock_state.model_dump.return_value = { + "revision": 1, + "credentials": [{"name": "api-token", "source_type": "inline", "revision": 1}], + "bindings": [], + } + mock_sb.credential_vault.create.return_value = mock_state + payload_path = tmp_path / "vault.yaml" + payload_path.write_text( + """ +credentials: + - name: api-token + source: + value: secret-token +bindings: [] +""".strip() + ) + + result = _invoke( + runner, + ["credential-vault", "create", "sb-1", "--file", str(payload_path), "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + mock_sb.credential_vault.create.assert_called_once_with( + credentials=[ + {"name": "api-token", "source": {"value": "secret-token"}}, + ], + bindings=[], + ) + mock_sb.close.assert_called_once() + assert "secret-token" not in result.output + data = json.loads(result.output) + assert data["revision"] == 1 + + def test_get_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_state = MagicMock() + mock_state.model_dump.return_value = { + "revision": 1, + "credentials": [], + "bindings": [], + } + mock_sb.credential_vault.get.return_value = mock_state + + result = _invoke( + runner, + ["credential-vault", "get", "sb-1", "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + mock_sb.credential_vault.get.assert_called_once_with() + data = json.loads(result.output) + assert data["credentials"] == [] + + def test_patch_reads_stdin_and_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_state = MagicMock() + mock_state.model_dump.return_value = { + "revision": 2, + "credentials": [{"name": "runtime-token", "source_type": "inline", "revision": 2}], + "bindings": [], + } + mock_sb.credential_vault.patch.return_value = mock_state + payload = json.dumps({ + "expectedRevision": 1, + "credentials": { + "add": [ + { + "name": "runtime-token", + "source": {"value": "runtime-secret"}, + } + ] + }, + }) + + result = _invoke( + runner, + ["credential-vault", "patch", "sb-1", "--file", "-", "-o", "json"], + sandbox=mock_sb, + input=payload, + ) + + assert result.exit_code == 0 + mock_sb.credential_vault.patch.assert_called_once_with( + expected_revision=1, + credentials={ + "add": [ + { + "name": "runtime-token", + "source": {"value": "runtime-secret"}, + } + ] + }, + bindings=None, + ) + assert "runtime-secret" not in result.output + data = json.loads(result.output) + assert data["revision"] == 2 + + def test_patch_rejects_empty_mutation_payload( + self, runner: CliRunner, tmp_path: Path + ) -> None: + mock_sb = MagicMock() + payload_path = tmp_path / "empty.yaml" + payload_path.write_text("expectedRevision: 1\n") + + result = _invoke( + runner, + ["credential-vault", "patch", "sb-1", "--file", str(payload_path)], + sandbox=mock_sb, + ) + + assert result.exit_code != 0 + assert "must include credentials or bindings" in result.output + mock_sb.credential_vault.patch.assert_not_called() + + def test_delete_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-1" + + result = _invoke( + runner, + ["credential-vault", "delete", "sb-1", "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + mock_sb.credential_vault.delete.assert_called_once_with() + data = json.loads(result.output) + assert data == {"sandbox_id": "sb-1", "status": "deleted"} + + def test_credential_list_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + credential = MagicMock() + credential.model_dump.return_value = { + "name": "api-token", + "source_type": "inline", + "revision": 1, + } + mock_sb.credential_vault.list_credentials.return_value = [credential] + + result = _invoke( + runner, + ["credential-vault", "credential", "list", "sb-1", "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + mock_sb.credential_vault.list_credentials.assert_called_once_with() + data = json.loads(result.output) + assert data[0]["name"] == "api-token" + + def test_binding_get_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + binding = MagicMock() + binding.model_dump.return_value = { + "name": "api-binding", + "revision": 1, + "match": {"hosts": ["api.example.com"]}, + "auth": {"type": "apiKey", "name": "x-api-key"}, + } + mock_sb.credential_vault.get_binding.return_value = binding + + result = _invoke( + runner, + ["credential-vault", "binding", "get", "sb-1", "api-binding", "-o", "json"], + sandbox=mock_sb, + ) + + assert result.exit_code == 0 + mock_sb.credential_vault.get_binding.assert_called_once_with("api-binding") + data = json.loads(result.output) + assert data["auth"]["type"] == "apiKey" + + +# --------------------------------------------------------------------------- +# Command execution +# --------------------------------------------------------------------------- + + +class TestCommandRun: + def test_background_run(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_execution = MagicMock() + mock_execution.id = "exec-123" + mock_sb.commands.run.return_value = mock_execution + + result = _invoke( + runner, + ["command", "run", "sb-1", "-d", "echo", "hello", "-o", "json"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["execution_id"] == "exec-123" + assert data["mode"] == "background" + + def test_foreground_run_rejects_json_output(self, runner: CliRunner) -> None: + result = _invoke( + runner, + ["command", "run", "sb-1", "-o", "json", "--", "echo", "hello"], + ) + assert result.exit_code != 0 + assert "Allowed values: raw" in result.output + + +class TestCommandInterrupt: + def test_interrupt_calls_sdk(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, ["command", "interrupt", "sb-1", "exec-789"], sandbox=mock_sb + ) + assert result.exit_code == 0 + mock_sb.commands.interrupt.assert_called_once_with("exec-789") + assert "Interrupted: exec-789" in result.output + + +class TestCommandSession: + def test_session_create(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_sb.id = "sb-1" + mock_sb.commands.create_session.return_value = "sess-123" + result = _invoke( + runner, + ["command", "session", "create", "sb-1", "--workdir", "/workspace", "-o", "json"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["session_id"] == "sess-123" + mock_sb.commands.create_session.assert_called_once_with(working_directory="/workspace") + + def test_session_run(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + mock_execution = MagicMock() + mock_execution.error = None + mock_sb.commands.run_in_session.return_value = mock_execution + result = _invoke( + runner, + ["command", "session", "run", "sb-1", "sess-123", "--timeout", "30s", "--", "pwd"], + sandbox=mock_sb, + output_format="table", + ) + assert result.exit_code == 0 + mock_sb.commands.run_in_session.assert_called_once() + assert mock_sb.commands.run_in_session.call_args.args[:2] == ("sess-123", "pwd") + assert mock_sb.commands.run_in_session.call_args.kwargs["timeout"] == timedelta(seconds=30) + + def test_session_delete(self, runner: CliRunner) -> None: + mock_sb = MagicMock() + result = _invoke( + runner, + ["command", "session", "delete", "sb-1", "sess-123"], + sandbox=mock_sb, + ) + assert result.exit_code == 0 + mock_sb.commands.delete_session.assert_called_once_with("sess-123") + assert "Deleted session: sess-123" in result.output + + def test_session_run_rejects_json_output(self, runner: CliRunner) -> None: + result = _invoke( + runner, + ["command", "session", "run", "sb-1", "sess-123", "-o", "json", "--", "pwd"], + ) + assert result.exit_code != 0 + assert "Invalid value for '-o' / '--output'" in result.output + + +# --------------------------------------------------------------------------- +# DevOps diagnostics +# --------------------------------------------------------------------------- + + +class TestDevopsCommands: + def test_logs_preserves_legacy_plain_text_filters_with_warning(self, runner: CliRunner) -> None: + mock_ctx = _build_mock_client_context() + response = MagicMock() + response.status_code = 200 + response.text = "sandbox logs" + response.raise_for_status = MagicMock() + devops_client = MagicMock() + devops_client.get.return_value = response + mock_ctx.get_devops_client.return_value = devops_client + + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx): + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + ["devops", "logs", "sb-1", "--tail", "100", "--since", "30m"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "deprecated" in result.output + assert "sandbox logs" in result.output + devops_client.get.assert_called_once_with( + "sandboxes/sb-1/diagnostics/logs", + params={"tail": 100, "since": "30m"}, + ) + + def test_events_preserves_legacy_limit_with_warning(self, runner: CliRunner) -> None: + mock_ctx = _build_mock_client_context() + response = MagicMock() + response.status_code = 200 + response.text = "sandbox events" + response.raise_for_status = MagicMock() + devops_client = MagicMock() + devops_client.get.return_value = response + mock_ctx.get_devops_client.return_value = devops_client + + with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ + patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx): + mock_resolve.return_value = mock_ctx.resolved_config + result = runner.invoke( + cli, + ["devops", "events", "sb-1", "--limit", "25"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "deprecated" in result.output + assert "sandbox events" in result.output + devops_client.get.assert_called_once_with( + "sandboxes/sb-1/diagnostics/events", + params={"limit": 25}, + ) + + +# --------------------------------------------------------------------------- +# Stable diagnostics +# --------------------------------------------------------------------------- + + +class TestDiagnosticsCommands: + def test_logs_requires_scope(self, runner: CliRunner) -> None: + result = _invoke(runner, ["diagnostics", "logs", "sb-1", "-o", "raw"]) + assert result.exit_code != 0 + assert "Missing option '--scope'" in result.output + + def test_logs_raw_prints_inline_content(self, runner: CliRunner) -> None: + manager = MagicMock() + manager.get_diagnostic_logs.return_value = DiagnosticContent( + sandboxId="sb-1", + kind="logs", + scope="container", + delivery="inline", + contentType="text/plain; charset=utf-8", + content="line 1\nline 2", + truncated=False, + ) + + result = _invoke( + runner, + ["diagnostics", "logs", "sb-1", "--scope", "container", "-o", "raw"], + manager=manager, + ) + + assert result.exit_code == 0 + assert "line 1\nline 2" in result.output + manager.get_diagnostic_logs.assert_called_once_with("sb-1", scope="container") + + def test_events_json_prints_descriptor(self, runner: CliRunner) -> None: + manager = MagicMock() + manager.get_diagnostic_events.return_value = DiagnosticContent( + sandboxId="sb-1", + kind="events", + scope="runtime", + delivery="url", + contentType="text/plain; charset=utf-8", + contentUrl="https://example.com/events.txt", + contentLength=12, + truncated=False, + ) + + result = _invoke( + runner, + ["diagnostics", "events", "sb-1", "--scope", "runtime", "-o", "json"], + manager=manager, + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["kind"] == "events" + assert data["scope"] == "runtime" + assert data["delivery"] == "url" + assert data["content_url"] == "https://example.com/events.txt" + assert data["content_length"] == 12 + manager.get_diagnostic_events.assert_called_once_with("sb-1", scope="runtime") + + def test_raw_url_delivery_prints_content_url(self, runner: CliRunner) -> None: + manager = MagicMock() + manager.get_diagnostic_logs.return_value = DiagnosticContent( + sandboxId="sb-1", + kind="logs", + scope="container", + delivery="url", + contentType="text/plain; charset=utf-8", + contentUrl="https://example.com/logs.txt", + truncated=False, + ) + + result = _invoke( + runner, + ["diagnostics", "logs", "sb-1", "--scope", "container", "-o", "raw"], + manager=manager, + ) + + assert result.exit_code == 0 + assert result.output.strip() == "https://example.com/logs.txt" diff --git a/cli/tests/test_config.py b/cli/tests/test_config.py new file mode 100644 index 0000000..3c8c195 --- /dev/null +++ b/cli/tests/test_config.py @@ -0,0 +1,180 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for opensandbox_cli.config — config loading and priority merging.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from opensandbox_cli.config import ( + DEFAULT_CONFIG_TEMPLATE, + init_config_file, + load_config_file, + resolve_config, +) + +# --------------------------------------------------------------------------- +# load_config_file +# --------------------------------------------------------------------------- + + +class TestLoadConfigFile: + def test_returns_empty_when_file_missing(self, tmp_path: Path) -> None: + result = load_config_file(tmp_path / "nonexistent.toml") + assert result == {} + + def test_parses_toml_file(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text( + '[connection]\napi_key = "abc"\ndomain = "example.com"\n' + ) + result = load_config_file(cfg) + assert result["connection"]["api_key"] == "abc" + assert result["connection"]["domain"] == "example.com" + + def test_parses_all_sections(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text( + '[connection]\napi_key = "k"\n\n' + '[output]\ncolor = false\n\n' + '[defaults]\nimage = "alpine"\ntimeout = "5m"\n' + ) + result = load_config_file(cfg) + assert result["output"]["color"] is False + assert result["defaults"]["image"] == "alpine" + assert result["defaults"]["timeout"] == "5m" + + +# --------------------------------------------------------------------------- +# resolve_config — priority: CLI > env > file > defaults +# --------------------------------------------------------------------------- + + +class TestResolveConfig: + def test_defaults_when_nothing_configured(self, tmp_path: Path) -> None: + cfg_path = tmp_path / "empty.toml" + cfg_path.write_text("") + result = resolve_config(config_path=cfg_path) + assert result["api_key"] is None + assert result["domain"] is None + assert result["protocol"] == "http" + assert result["request_timeout"] == 30 + assert result["use_server_proxy"] is False + assert result["color"] is True + + def test_file_values_override_defaults(self, tmp_path: Path) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text( + '[connection]\napi_key = "file-key"\ndomain = "file.host"\n' + 'protocol = "https"\nrequest_timeout = 60\nuse_server_proxy = true\n\n' + '[output]\ncolor = false\n\n' + '[defaults]\nimage = "node:20"\ntimeout = "15m"\n' + ) + result = resolve_config(config_path=cfg) + assert result["api_key"] == "file-key" + assert result["domain"] == "file.host" + assert result["protocol"] == "https" + assert result["request_timeout"] == 60 + assert result["use_server_proxy"] is True + assert result["color"] is False + assert result["default_image"] == "node:20" + assert result["default_timeout"] == "15m" + + def test_env_overrides_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text('[connection]\napi_key = "file-key"\ndomain = "file.host"\n') + + monkeypatch.setenv("OPEN_SANDBOX_API_KEY", "env-key") + monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "env.host") + monkeypatch.setenv("OPEN_SANDBOX_PROTOCOL", "https") + monkeypatch.setenv("OPEN_SANDBOX_REQUEST_TIMEOUT", "120") + monkeypatch.setenv("OPEN_SANDBOX_USE_SERVER_PROXY", "true") + + result = resolve_config(config_path=cfg) + assert result["api_key"] == "env-key" + assert result["domain"] == "env.host" + assert result["protocol"] == "https" + assert result["request_timeout"] == 120 + assert result["use_server_proxy"] is True + + def test_cli_overrides_everything(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = tmp_path / "config.toml" + cfg.write_text('[connection]\napi_key = "file-key"\n') + monkeypatch.setenv("OPEN_SANDBOX_API_KEY", "env-key") + + result = resolve_config( + cli_api_key="cli-key", + cli_domain="cli.host", + cli_protocol="https", + cli_timeout=999, + cli_use_server_proxy=True, + config_path=cfg, + ) + assert result["api_key"] == "cli-key" + assert result["domain"] == "cli.host" + assert result["protocol"] == "https" + assert result["request_timeout"] == 999 + assert result["use_server_proxy"] is True + + def test_invalid_timeout_env_falls_through(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = tmp_path / "empty.toml" + cfg.write_text("") + monkeypatch.setenv("OPEN_SANDBOX_REQUEST_TIMEOUT", "not-a-number") + result = resolve_config(config_path=cfg) + # Falls through to default 30 + assert result["request_timeout"] == 30 + + def test_invalid_bool_env_falls_through(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cfg = tmp_path / "empty.toml" + cfg.write_text("") + monkeypatch.setenv("OPEN_SANDBOX_USE_SERVER_PROXY", "not-a-bool") + result = resolve_config(config_path=cfg) + assert result["use_server_proxy"] is False + + +# --------------------------------------------------------------------------- +# init_config_file +# --------------------------------------------------------------------------- + + +class TestInitConfigFile: + def test_creates_default_config(self, tmp_path: Path) -> None: + cfg_path = tmp_path / ".opensandbox" / "config.toml" + result = init_config_file(cfg_path) + assert result == cfg_path + assert cfg_path.exists() + content = cfg_path.read_text() + assert "[connection]" in content + assert "[output]" in content + assert "[defaults]" in content + + def test_refuses_overwrite_without_force(self, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("existing") + with pytest.raises(FileExistsError, match="already exists"): + init_config_file(cfg_path) + + def test_force_overwrites(self, tmp_path: Path) -> None: + cfg_path = tmp_path / "config.toml" + cfg_path.write_text("old content") + init_config_file(cfg_path, force=True) + assert cfg_path.read_text() == DEFAULT_CONFIG_TEMPLATE + + def test_creates_parent_directories(self, tmp_path: Path) -> None: + cfg_path = tmp_path / "a" / "b" / "c" / "config.toml" + init_config_file(cfg_path) + assert cfg_path.exists() diff --git a/cli/tests/test_output.py b/cli/tests/test_output.py new file mode 100644 index 0000000..3f09f65 --- /dev/null +++ b/cli/tests/test_output.py @@ -0,0 +1,136 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for opensandbox_cli.output — table, JSON, YAML rendering.""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import BaseModel + +from opensandbox_cli.output import OutputFormatter + +# --------------------------------------------------------------------------- +# Test models +# --------------------------------------------------------------------------- + + +class FakeItem(BaseModel): + id: str + name: str + score: int + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + + +class TestJsonOutput: + def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("json", color=False) + fmt.print_dict({"key": "value", "num": 42}) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"key": "value", "num": 42} + + def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("json", color=False) + item = FakeItem(id="abc", name="test", score=100) + fmt.print_model(item) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["id"] == "abc" + assert data["name"] == "test" + assert data["score"] == 100 + + def test_print_models(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("json", color=False) + items = [ + FakeItem(id="1", name="a", score=10), + FakeItem(id="2", name="b", score=20), + ] + fmt.print_models(items, columns=["id", "name", "score"]) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert len(data) == 2 + assert data[0]["id"] == "1" + assert data[1]["name"] == "b" + + def test_success_renders_structured_json(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("json", color=False) + fmt.success("done") + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data == {"status": "ok", "message": "done"} + + +# --------------------------------------------------------------------------- +# YAML output +# --------------------------------------------------------------------------- + + +class TestYamlOutput: + def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("yaml", color=False) + fmt.print_dict({"key": "value"}) + captured = capsys.readouterr() + assert "key: value" in captured.out + + def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("yaml", color=False) + item = FakeItem(id="x", name="y", score=5) + fmt.print_model(item) + captured = capsys.readouterr() + assert "id: x" in captured.out + assert "name: y" in captured.out + assert "score: 5" in captured.out + + +# --------------------------------------------------------------------------- +# Table output +# --------------------------------------------------------------------------- + + +class TestTableOutput: + def test_print_dict_contains_values(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("table", color=False) + fmt.print_dict({"host": "example.com", "port": 8080}, title="Config") + captured = capsys.readouterr() + assert any(cell == "example.com" for cell in captured.out.split()) + assert "8080" in captured.out + assert "Config" in captured.out + + def test_print_dict_none_renders_dash(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("table", color=False) + fmt.print_dict({"key": None}) + captured = capsys.readouterr() + assert "-" in captured.out + + def test_print_models_shows_headers(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("table", color=False) + items = [FakeItem(id="1", name="a", score=10)] + fmt.print_models(items, columns=["id", "name", "score"], title="Items") + captured = capsys.readouterr() + assert "ID" in captured.out + assert "NAME" in captured.out + assert "SCORE" in captured.out + + def test_print_text_ignores_format(self, capsys: pytest.CaptureFixture[str]) -> None: + fmt = OutputFormatter("json", color=False) + fmt.print_text("hello world") + captured = capsys.readouterr() + assert captured.out.strip() == "hello world" diff --git a/cli/tests/test_skills.py b/cli/tests/test_skills.py new file mode 100644 index 0000000..f8719b3 --- /dev/null +++ b/cli/tests/test_skills.py @@ -0,0 +1,671 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for bundled skill install/list/show/uninstall flows.""" + +from __future__ import annotations + +import importlib.resources +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from click import Command, Group, Option +from click.testing import CliRunner + +from opensandbox_cli.commands.skills import _TARGETS, skills_group +from opensandbox_cli.main import cli +from opensandbox_cli.output import OutputFormatter +from opensandbox_cli.skill_registry import list_builtin_skills + + +@pytest.fixture() +def isolated_skill_targets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + patched = { + "claude": { + **_TARGETS["claude"], + "scopes": { + "project": { + **_TARGETS["claude"]["scopes"]["project"], + "dest_dir": tmp_path / ".claude" / "skills", + }, + "global": { + **_TARGETS["claude"]["scopes"]["global"], + "dest_dir": tmp_path / "home" / ".claude" / "skills", + }, + }, + }, + "cursor": { + **_TARGETS["cursor"], + "scopes": { + "project": { + **_TARGETS["cursor"]["scopes"]["project"], + "dest_dir": tmp_path / ".cursor" / "rules", + }, + "global": { + **_TARGETS["cursor"]["scopes"]["global"], + "dest_dir": tmp_path / "home" / ".cursor" / "rules", + }, + }, + }, + "codex": { + **_TARGETS["codex"], + "scopes": { + "project": { + **_TARGETS["codex"]["scopes"]["project"], + "dest_dir": tmp_path / ".codex" / "skills", + }, + "global": { + **_TARGETS["codex"]["scopes"]["global"], + "dest_dir": tmp_path / "home" / ".codex" / "skills", + }, + }, + }, + "copilot": { + **_TARGETS["copilot"], + "scopes": { + "project": { + **_TARGETS["copilot"]["scopes"]["project"], + "dest_file": tmp_path / ".github" / "copilot-instructions.md", + }, + "global": { + **_TARGETS["copilot"]["scopes"]["global"], + "dest_file": tmp_path / "home" / ".github" / "copilot-instructions.md", + }, + }, + }, + "windsurf": { + **_TARGETS["windsurf"], + "scopes": { + "project": { + **_TARGETS["windsurf"]["scopes"]["project"], + "dest_file": tmp_path / ".windsurfrules", + }, + "global": { + **_TARGETS["windsurf"]["scopes"]["global"], + "dest_file": tmp_path / "home" / ".windsurfrules", + }, + }, + }, + "cline": { + **_TARGETS["cline"], + "scopes": { + "project": { + **_TARGETS["cline"]["scopes"]["project"], + "dest_file": tmp_path / ".clinerules", + }, + "global": { + **_TARGETS["cline"]["scopes"]["global"], + "dest_file": tmp_path / "home" / ".clinerules", + }, + }, + }, + "opencode": { + **_TARGETS["opencode"], + "scopes": { + "project": { + **_TARGETS["opencode"]["scopes"]["project"], + "dest_dir": tmp_path / ".agents" / "skills", + }, + "global": { + **_TARGETS["opencode"]["scopes"]["global"], + "dest_dir": tmp_path / "home" / ".agents" / "skills", + }, + }, + }, + } + monkeypatch.setattr("opensandbox_cli.commands.skills._TARGETS", patched) + + +class TestSkillsCommands: + def test_list_supports_json_output( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke( + skills_group, + ["list"], + obj=SimpleNamespace(output=OutputFormatter("json", color=False)), + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert "skills" in data + assert "targets" in data + assert any(skill["slug"] == "sandbox-troubleshooting" for skill in data["skills"]) + assert any(skill["slug"] == "sandbox-lifecycle" for skill in data["skills"]) + + def test_show_supports_json_output( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke( + skills_group, + ["show", "sandbox-lifecycle"], + obj=SimpleNamespace(output=OutputFormatter("json", color=False)), + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["skill"] == "sandbox-lifecycle" + assert data["title"] == "OpenSandbox Sandbox Lifecycle" + assert '"defaultAction": "deny"' in data["json_shapes"] + + def test_install_without_args_prints_guidance_and_does_not_install( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke(skills_group, ["install"]) + + assert result.exit_code != 0 + assert "Install guidance:" in result.output + assert "osb skills install --target --scope " in result.output + assert not (tmp_path / ".claude" / "skills" / "sandbox-troubleshooting.md").exists() + + def test_install_with_skill_but_without_target_prints_guidance( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["install", "sandbox-troubleshooting"]) + + assert result.exit_code != 0 + assert "Install guidance:" in result.output + assert "Missing required option '--target'" in result.output + + def test_install_with_all_builtins_but_without_target_prints_guidance( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["install", "--all-builtins"]) + + assert result.exit_code != 0 + assert "Install guidance:" in result.output + assert "Missing required option '--target'" in result.output + + def test_install_copy_target_creates_named_skill_file( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "claude", "--scope", "project"], + ) + + assert result.exit_code == 0 + dest = tmp_path / ".claude" / "skills" / "sandbox-troubleshooting.md" + assert dest.exists() + content = dest.read_text(encoding="utf-8") + assert content.startswith("---\nname: sandbox-troubleshooting") + + def test_install_codex_creates_skill_directory_with_frontmatter( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"], + ) + + assert result.exit_code == 0 + dest = tmp_path / ".codex" / "skills" / "sandbox-troubleshooting" / "SKILL.md" + content = dest.read_text(encoding="utf-8") + assert content.startswith("---\nname: sandbox-troubleshooting") + assert "# OpenSandbox Sandbox Troubleshooting" in content + + def test_install_reports_already_present_without_prompt( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + first = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"], + ) + assert first.exit_code == 0 + + second = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"], + ) + + assert second.exit_code == 0 + assert "already_present" in second.output + dest = tmp_path / ".codex" / "skills" / "sandbox-troubleshooting" / "SKILL.md" + assert dest.exists() + + def test_install_all_builtins_to_codex_creates_skill_directories( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "--all-builtins", "--target", "codex", "--scope", "project"], + ) + + assert result.exit_code == 0 + assert "Install plan:" in result.output + assert "install one file per skill" in result.output + for skill in list_builtin_skills(): + dest = tmp_path / ".codex" / "skills" / skill.slug / "SKILL.md" + assert dest.exists() + + def test_install_supports_json_output( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "network-egress", "--target", "codex", "--scope", "project"], + obj=SimpleNamespace(output=OutputFormatter("json", color=False)), + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["requires_restart"] is True + assert data["operations"][0]["skill"] == "network-egress" + assert data["operations"][0]["status"] == "installed" + + def test_install_rejects_skill_name_and_all_builtins_together( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--all-builtins"], + ) + + assert result.exit_code != 0 + assert "either a skill name or --all-builtins" in result.output + + def test_install_to_global_codex_uses_global_path( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "global"], + ) + + assert result.exit_code == 0 + assert (tmp_path / "home" / ".codex" / "skills" / "sandbox-troubleshooting" / "SKILL.md").exists() + + def test_install_to_project_opencode_creates_skill_directory( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "network-egress", "--target", "opencode", "--scope", "project"], + ) + + assert result.exit_code == 0 + dest = tmp_path / ".agents" / "skills" / "network-egress" / "SKILL.md" + assert dest.exists() + assert dest.read_text(encoding="utf-8").startswith("---\nname: network-egress") + + def test_show_prints_skill_metadata_and_content( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["show", "file-operations"]) + + assert result.exit_code == 0 + assert "Skill: file-operations" in result.output + assert "Title: OpenSandbox File Operations" in result.output + assert "When To Use:" in result.output + assert "Quick Start:" in result.output + assert "Minimal Closed Loops:" in result.output + assert "Full Skill:" in result.output + assert "osb file cat" in result.output + + def test_show_supports_new_network_egress_skill( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["show", "network-egress"]) + + assert result.exit_code == 0 + assert "Skill: network-egress" in result.output + assert "Quick Start:" in result.output + assert "osb egress patch" in result.output + + def test_show_supports_credential_vault_skill( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["show", "credential-vault"]) + + assert result.exit_code == 0 + assert "Skill: credential-vault" in result.output + assert "Credential Vault" in result.output + assert "osb credential-vault create" in result.output + + def test_show_surfaces_json_shapes_for_lifecycle_skill( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["show", "sandbox-lifecycle"]) + + assert result.exit_code == 0 + assert "JSON Shapes:" in result.output + assert '"defaultAction": "deny"' in result.output + assert '"mountPath": "/workspace/data"' in result.output + + def test_list_reports_all_builtins( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + result = runner.invoke(skills_group, ["list"]) + + assert result.exit_code == 0 + assert "aggregate into one instructions file" in result.output + assert "install one file per skill" in result.output + for skill in list_builtin_skills(): + assert skill.slug in result.output + + def test_list_reports_not_installed_when_append_target_file_exists_without_marker( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + dest = tmp_path / ".github" / "copilot-instructions.md" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("user custom instructions\n", encoding="utf-8") + + result = runner.invoke(skills_group, ["list"]) + + assert result.exit_code == 0 + assert "copilot" in result.output + assert "not installed" in result.output + + def test_uninstall_append_target_preserves_non_skill_content( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + install_result = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project"], + ) + assert install_result.exit_code == 0 + + dest = tmp_path / ".github" / "copilot-instructions.md" + original = dest.read_text(encoding="utf-8") + dest.write_text("team rules\n\n" + original, encoding="utf-8") + + uninstall_result = runner.invoke( + skills_group, + ["uninstall", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project"], + ) + + assert uninstall_result.exit_code == 0 + assert dest.read_text(encoding="utf-8") == "team rules\n" + + def test_uninstall_supports_json_output( + self, + runner: CliRunner, + isolated_skill_targets: None, + ) -> None: + install_result = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"], + ) + assert install_result.exit_code == 0 + + uninstall_result = runner.invoke( + skills_group, + ["uninstall", "sandbox-troubleshooting", "--target", "codex", "--scope", "project"], + obj=SimpleNamespace(output=OutputFormatter("json", color=False)), + ) + + assert uninstall_result.exit_code == 0 + data = json.loads(uninstall_result.output) + assert data["operations"][0]["status"] == "removed" + + def test_reinstall_append_target_does_not_duplicate_skill_block( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + first = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project"], + ) + assert first.exit_code == 0 + + second = runner.invoke( + skills_group, + ["install", "sandbox-troubleshooting", "--target", "copilot", "--scope", "project", "--force"], + ) + + assert second.exit_code == 0 + dest = tmp_path / ".github" / "copilot-instructions.md" + content = dest.read_text(encoding="utf-8") + assert content.count("") == 1 + + def test_install_all_builtins_to_copy_target_creates_new_skill_files( + self, + runner: CliRunner, + isolated_skill_targets: None, + tmp_path: Path, + ) -> None: + result = runner.invoke( + skills_group, + ["install", "--all-builtins", "--target", "claude", "--scope", "project"], + ) + + assert result.exit_code == 0 + assert "Install plan:" in result.output + assert "install one file per skill" in result.output + assert (tmp_path / ".claude" / "skills" / "network-egress.md").exists() + assert (tmp_path / ".claude" / "skills" / "sandbox-troubleshooting.md").exists() + + +def _read_builtin_skill(package_file: str) -> str: + resource = importlib.resources.files("opensandbox_cli") / "skills" / package_file + return Path(str(resource)).read_text(encoding="utf-8") + + +def _command(path: list[str]) -> Command: + current: Command = cli + for part in path: + assert isinstance(current, Group), f"{current.name} is not a command group" + current = current.commands[part] + return current + + +def _option_names(command: Command) -> set[str]: + names: set[str] = set() + for param in command.params: + if isinstance(param, Option): + names.update(param.opts) + names.update(param.secondary_opts) + return names + + +class TestSkillContentQuality: + def test_sandbox_troubleshooting_keeps_triage_and_diagnostics_contract(self) -> None: + content = _read_builtin_skill("opensandbox-sandbox-troubleshooting.md") + + assert "## Triage Order" in content + assert "osb sandbox get -o json" in content + assert "osb diagnostics events --scope lifecycle -o raw" in content + assert "osb diagnostics events --scope runtime -o raw" in content + assert "osb diagnostics logs --scope container -o raw" in content + assert "## Diagnostics Streams" in content + assert "## Evidence Semantics" in content + assert "## URL Delivery" in content + assert "truncated: true" in content + assert "osb devops" not in content + assert "## Symptom To Command Mapping" in content + + def test_builtin_skills_do_not_reference_legacy_devops_diagnostics(self) -> None: + skills_dir = Path("src/opensandbox_cli/skills") + forbidden = ( + "osb devops inspect", + "osb devops summary", + "devops inspect", + "devops summary", + ) + + violations: list[str] = [] + for skill_path in sorted(skills_dir.glob("*.md")): + content = skill_path.read_text(encoding="utf-8") + for term in forbidden: + if term in content: + violations.append(f"{skill_path.name}: {term}") + + assert violations == [] + + def test_lifecycle_skill_keeps_json_shapes_and_health_guidance(self) -> None: + content = _read_builtin_skill("opensandbox-sandbox-lifecycle.md") + + assert "## JSON Shapes" in content + assert '"defaultAction": "deny"' in content + assert '"mountPath": "/workspace/data"' in content + assert "Prefer `health` over assuming readiness from `create` output alone" in content + + def test_sandbox_troubleshooting_keeps_cli_first_and_http_fallback_guidance(self) -> None: + content = _read_builtin_skill("opensandbox-sandbox-troubleshooting.md") + + assert "## Operating Rules" in content + assert "use CLI commands when `osb` is available" in content + assert "use HTTP only when the CLI is unavailable" in content + assert "Use raw HTTP only after domain, protocol, and API key expectations are explicit." in content + assert "## Symptom To Command Mapping" in content + + +class TestSkillCliAlignment: + def test_command_execution_skill_matches_command_cli(self) -> None: + run_cmd = _command(["command", "run"]) + session_run_cmd = _command(["command", "session", "run"]) + logs_cmd = _command(["command", "logs"]) + session_delete_cmd = _command(["command", "session", "delete"]) + + assert {"-d", "--background", "-w", "--workdir", "-t", "--timeout", "-o", "--output"} <= _option_names(run_cmd) + assert {"-w", "--workdir", "-t", "--timeout", "-o", "--output"} <= _option_names(session_run_cmd) + assert {"--cursor", "-o", "--output"} <= _option_names(logs_cmd) + assert {"-o", "--output"} <= _option_names(session_delete_cmd) + + def test_sandbox_lifecycle_skill_matches_sandbox_cli(self) -> None: + create_cmd = _command(["sandbox", "create"]) + resume_cmd = _command(["sandbox", "resume"]) + endpoint_cmd = _command(["sandbox", "endpoint"]) + metrics_cmd = _command(["sandbox", "metrics"]) + + assert { + "-i", "--image", "-t", "--timeout", "--entrypoint", "--network-policy-file", + "--credential-proxy", "--volumes-file", "--skip-health-check", + "--ready-timeout", "-o", "--output", + } <= _option_names(create_cmd) + assert {"--skip-health-check", "--resume-timeout", "-o", "--output"} <= _option_names(resume_cmd) + assert {"-p", "--port", "-o", "--output"} <= _option_names(endpoint_cmd) + assert {"--watch", "-o", "--output"} <= _option_names(metrics_cmd) + + def test_file_operations_skill_matches_file_cli(self) -> None: + expected_subcommands = { + "cat", "write", "upload", "download", "rm", "mv", "mkdir", + "rmdir", "search", "info", "chmod", "replace", + } + file_group = _command(["file"]) + assert isinstance(file_group, Group) + assert expected_subcommands <= set(file_group.commands) + + assert {"-c", "--content", "--encoding", "--mode", "--owner", "--group", "-o", "--output"} <= _option_names( + _command(["file", "write"]) + ) + assert {"-p", "--pattern", "-o", "--output"} <= _option_names(_command(["file", "search"])) + assert {"--mode", "--owner", "--group", "-o", "--output"} <= _option_names(_command(["file", "chmod"])) + assert {"--old", "--new", "-o", "--output"} <= _option_names(_command(["file", "replace"])) + + def test_network_egress_diagnostics_and_devops_skills_match_cli(self) -> None: + patch_cmd = _command(["egress", "patch"]) + diag_logs_cmd = _command(["diagnostics", "logs"]) + diag_events_cmd = _command(["diagnostics", "events"]) + logs_cmd = _command(["devops", "logs"]) + events_cmd = _command(["devops", "events"]) + summary_cmd = _command(["devops", "summary"]) + + assert {"--rule", "-o", "--output"} <= _option_names(patch_cmd) + assert {"--scope", "-s", "-o", "--output"} <= _option_names(diag_logs_cmd) + assert {"--scope", "-s", "-o", "--output"} <= _option_names(diag_events_cmd) + assert {"--tail", "-n", "--since", "-s", "-o", "--output"} <= _option_names(logs_cmd) + assert {"--limit", "-l", "-o", "--output"} <= _option_names(events_cmd) + assert {"--tail", "-n", "--event-limit", "-o", "--output"} <= _option_names(summary_cmd) + + def test_credential_vault_skill_matches_cli(self) -> None: + vault_group = _command(["credential-vault"]) + assert isinstance(vault_group, Group) + assert {"create", "get", "patch", "delete", "credential", "binding"} <= set(vault_group.commands) + + assert {"--file", "-o", "--output"} <= _option_names(_command(["credential-vault", "create"])) + assert {"--file", "-o", "--output"} <= _option_names(_command(["credential-vault", "patch"])) + assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "get"])) + assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "delete"])) + assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "credential", "list"])) + assert {"-o", "--output"} <= _option_names(_command(["credential-vault", "binding", "get"])) + + def test_skill_osb_examples_use_explicit_output_formats(self) -> None: + allowed_without_output = { + "osb --version", + } + skills_dir = Path("src/opensandbox_cli/skills") + + missing_output: list[str] = [] + for skill_path in sorted(skills_dir.glob("*.md")): + in_block = False + for line in skill_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("```bash"): + in_block = True + continue + if in_block and stripped == "```": + in_block = False + continue + if not in_block or not stripped.startswith("osb "): + continue + if stripped in allowed_without_output: + continue + if " -o " not in stripped: + missing_output.append(f"{skill_path.name}: {stripped}") + + assert missing_output == [] diff --git a/cli/tests/test_utils.py b/cli/tests/test_utils.py new file mode 100644 index 0000000..bf48129 --- /dev/null +++ b/cli/tests/test_utils.py @@ -0,0 +1,108 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for opensandbox_cli.utils — duration parsing, key-value type, error handling.""" + +from __future__ import annotations + +from datetime import timedelta + +import click +import pytest + +from opensandbox_cli.utils import DURATION, KEY_VALUE, parse_duration + +# --------------------------------------------------------------------------- +# parse_duration +# --------------------------------------------------------------------------- + + +class TestParseDuration: + @pytest.mark.parametrize( + "input_str, expected", + [ + ("10", timedelta(seconds=10)), + ("0", timedelta(seconds=0)), + ("10s", timedelta(seconds=10)), + ("5m", timedelta(minutes=5)), + ("2h", timedelta(hours=2)), + ("1h30m", timedelta(hours=1, minutes=30)), + ("1h30m45s", timedelta(hours=1, minutes=30, seconds=45)), + ("90s", timedelta(seconds=90)), + ], + ) + def test_valid_durations(self, input_str: str, expected: timedelta) -> None: + assert parse_duration(input_str) == expected + + @pytest.mark.parametrize( + "input_str", + [ + "", + "abc", + "10x", + "m10", + "-5m", + ], + ) + def test_invalid_durations(self, input_str: str) -> None: + with pytest.raises(click.BadParameter): + parse_duration(input_str) + + def test_strips_whitespace(self) -> None: + assert parse_duration(" 10m ") == timedelta(minutes=10) + + +# --------------------------------------------------------------------------- +# DurationType (Click param type) +# --------------------------------------------------------------------------- + + +class TestDurationType: + def test_converts_string(self) -> None: + result = DURATION.convert("5m", None, None) + assert result == timedelta(minutes=5) + + def test_passes_through_timedelta(self) -> None: + td = timedelta(hours=1) + result = DURATION.convert(td, None, None) # type: ignore[arg-type] + assert result is td + + def test_invalid_raises_bad_parameter(self) -> None: + with pytest.raises(click.exceptions.BadParameter): + DURATION.convert("invalid", None, None) + + +# --------------------------------------------------------------------------- +# KeyValueType (Click param type) +# --------------------------------------------------------------------------- + + +class TestKeyValueType: + def test_parses_simple_kv(self) -> None: + assert KEY_VALUE.convert("FOO=bar", None, None) == ("FOO", "bar") + + def test_value_can_contain_equals(self) -> None: + assert KEY_VALUE.convert("key=a=b=c", None, None) == ("key", "a=b=c") + + def test_empty_value(self) -> None: + assert KEY_VALUE.convert("key=", None, None) == ("key", "") + + def test_missing_equals_fails(self) -> None: + with pytest.raises(click.exceptions.BadParameter): + KEY_VALUE.convert("no-equals", None, None) + + def test_passes_through_tuple(self) -> None: + t = ("key", "val") + result = KEY_VALUE.convert(t, None, None) # type: ignore[arg-type] + assert result is t diff --git a/cli/uv.lock b/cli/uv.lock new file mode 100644 index 0000000..be0135b --- /dev/null +++ b/cli/uv.lock @@ -0,0 +1,751 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "opensandbox" +source = { editable = "../sdks/sandbox/python" } +dependencies = [ + { name = "attrs" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dateutil" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", specifier = ">=21.3.0" }, + { name = "httpx", specifier = ">=0.27.0,<1.0" }, + { name = "pydantic", specifier = ">=2.4.2,<3.0" }, + { name = "python-dateutil", specifier = ">=2.8.2,<3.0" }, + { name = "redis", marker = "extra == 'pool-redis'", specifier = ">=5.0,<6.0" }, +] +provides-extras = ["pool-redis"] + +[package.metadata.requires-dev] +dev = [ + { name = "openapi-python-client", specifier = ">=0.28.0" }, + { name = "pyright", specifier = ">=1.1.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "pytest-cov", specifier = ">=4.0.0" }, + { name = "redis", specifier = ">=5.0,<6.0" }, + { name = "ruff", specifier = ">=0.14.8" }, +] + +[[package]] +name = "opensandbox-cli" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "opensandbox" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.0,<9.0" }, + { name = "opensandbox", editable = "../sdks/sandbox/python" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, + { name = "rich", specifier = ">=13.0.0,<14.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.0" }, + { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-cov", specifier = ">=4.0.0" }, + { name = "ruff", specifier = ">=0.14.8" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/components/egress/Dockerfile b/components/egress/Dockerfile new file mode 100644 index 0000000..7df26f0 --- /dev/null +++ b/components/egress/Dockerfile @@ -0,0 +1,149 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM golang:1.25.9-bookworm AS builder + +WORKDIR /workspace + +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown +ARG GOFLAGS= +ARG LDFLAGS= +ARG CGO_ENABLED=0 +ARG CC= +ARG CXX= +ARG CFLAGS= +ARG CXXFLAGS= +ARG CGO_CFLAGS= +ARG CGO_CXXFLAGS= +ARG CGO_LDFLAGS= + +# Copy only go mod/sum first for better caching +COPY components/egress/go.mod components/egress/go.sum ./components/egress/ +# Bring internal module so replace ../internal works during download/build +COPY components/internal ./components/internal + +WORKDIR /workspace/components/egress + +# Static-ish build (no cgo by default) to simplify runtime deps. +RUN go mod download + +# Pre-download internal-module deps for the supervisor build below. +RUN cd /workspace/components/internal && go mod download + +# Copy the rest of the egress sources +COPY components/egress ./ +RUN if [ -n "${CC}" ]; then export CC; fi; \ + if [ -n "${CXX}" ]; then export CXX; fi; \ + export CGO_ENABLED="${CGO_ENABLED}" \ + CGO_CFLAGS="${CGO_CFLAGS:-${CFLAGS}}" \ + CGO_CXXFLAGS="${CGO_CXXFLAGS:-${CXXFLAGS}}" \ + CGO_LDFLAGS="${CGO_LDFLAGS}"; \ + go build ${GOFLAGS} -trimpath -buildvcs=false \ + -ldflags "${LDFLAGS} -buildid= -B none \ + -X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ + -o /out/egress . + +# Build the opensandbox-supervisor binary from the internal module. +# Installed alongside /egress so a future ENTRYPOINT switch can wrap egress +# without changing this stage again. +RUN cd /workspace/components/internal && \ + if [ -n "${CC}" ]; then export CC; fi; \ + if [ -n "${CXX}" ]; then export CXX; fi; \ + export CGO_ENABLED="${CGO_ENABLED}" \ + CGO_CFLAGS="${CGO_CFLAGS:-${CFLAGS}}" \ + CGO_CXXFLAGS="${CGO_CXXFLAGS:-${CXXFLAGS}}" \ + CGO_LDFLAGS="${CGO_LDFLAGS}"; \ + go build ${GOFLAGS} -trimpath -buildvcs=false \ + -ldflags "${LDFLAGS} -buildid= -B none \ + -X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ + -o /out/opensandbox-supervisor ./cmd/supervisor + +FROM debian:bookworm-slim + +# iptables is needed for DNS REDIRECT; ca-certificates for TLS to upstream resolvers +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + iptables \ + iproute2 \ + nftables \ + ca-certificates \ + sudo \ + curl \ + wget \ + net-tools \ + dnsutils \ + netcat-openbsd \ + iputils-ping \ + traceroute \ + telnet \ + tcpdump \ + nmap \ + htop \ + procps \ + strace \ + lsof \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* + +# Python mitmproxy (transparent mode): mitmdump runs as user mitmproxy; iptables skips this uid. +# /var/lib/mitmproxy is mitm's home, used as the confdir (CA + config.yaml live under .mitmproxy/). +RUN useradd -r -u 10042 -d /var/lib/mitmproxy -s /usr/sbin/nologin mitmproxy \ + && mkdir -p /var/lib/mitmproxy/.mitmproxy \ + && chown -R mitmproxy:mitmproxy /var/lib/mitmproxy \ + && pip3 install --no-cache-dir --break-system-packages 'mitmproxy>=10,<11' \ + && (command -v mitmdump && mitmdump --version) \ + && mkdir -p /var/egress/mitmscripts + +# Static mitmproxy options (mode, listen_host, connection_strategy, stream_large_bodies, +# http2, ignore_hosts, ssl_verify_upstream_trusted_confdir). mitmdump auto-loads +# config.yaml from its confdir. Dynamic per-deployment options stay env-driven and +# are applied as --set by launch.go (which overrides values declared here). +COPY components/egress/mitmproxy/config.yaml /var/lib/mitmproxy/.mitmproxy/config.yaml +RUN chown mitmproxy:mitmproxy /var/lib/mitmproxy/.mitmproxy/config.yaml \ + && chmod 0644 /var/lib/mitmproxy/.mitmproxy/config.yaml + +# All egress runtime artifacts live under one directory to keep paths grouped. +COPY --from=builder /out/egress /opt/opensandbox-egress/egress +COPY --from=builder /out/opensandbox-supervisor /opt/opensandbox-egress/supervisor +# Pre-start hook: reap any mitmdump left over from a previous crashed +# egress so the new launch can bind the transparent-MITM listen port. +# Intentionally does NOT touch iptables/nft rules — the sidecar shares +# a network namespace with the workload, so leaving rules in place keeps +# egress filtering active across the supervisor's backoff window. +COPY components/egress/scripts/cleanup.sh /opt/opensandbox-egress/cleanup.sh +RUN chmod 0755 /opt/opensandbox-egress/cleanup.sh \ + /opt/opensandbox-egress/egress \ + /opt/opensandbox-egress/supervisor \ + && ln -s /opt/opensandbox-egress/egress /egress + +COPY components/egress/mitmscripts /var/egress/mitmscripts + +# Supervisor wraps the egress binary: restarts on crash with backoff and +# forwards SIGTERM gracefully. The cleanup hook runs only as pre-start; +# running it on post-exit would tear down enforcement during the backoff +# window and leave the workload unprotected. +# Expects OPENSANDBOX_NETWORK_POLICY env at runtime. +ENTRYPOINT ["/opt/opensandbox-egress/supervisor", \ + "--pre-start=/opt/opensandbox-egress/cleanup.sh", \ + "--name=egress", \ + "--grace-period=20s", \ + "--", \ + "/opt/opensandbox-egress/egress"] diff --git a/components/egress/Dockerfile.dockerignore b/components/egress/Dockerfile.dockerignore new file mode 100644 index 0000000..b9953ec --- /dev/null +++ b/components/egress/Dockerfile.dockerignore @@ -0,0 +1,5 @@ +** +!components/egress/** +!components/internal/** +!egress/** +!internal/** diff --git a/components/egress/README.md b/components/egress/README.md new file mode 100644 index 0000000..26a2f98 --- /dev/null +++ b/components/egress/README.md @@ -0,0 +1,4 @@ +# OpenSandbox Egress + +Documentation: [docs/components/egress.md](../../docs/components/egress.md) + diff --git a/components/egress/RELEASE_NOTES.md b/components/egress/RELEASE_NOTES.md new file mode 100644 index 0000000..eed4e7f --- /dev/null +++ b/components/egress/RELEASE_NOTES.md @@ -0,0 +1,89 @@ +# components/egress 1.0.4 + +## What's New + +### ✨ Features +- persist egress policy to local file by env `OPENSANDBOX_EGRESS_POLICY_FILE` (#525) + +### 🐛 Bug Fixes +- Clamp nft element timeouts to 60–360s (was 60–300s) so the upper bound still matches a 300s DNS TTL cap plus slack; raise dyn_allow_* set timeout to 360s (#595) +- Avoid nft "conflicting intervals" when static allow/deny lists overlap (e.g. CGNAT + host inside). Add normalizeNFTIntervalSet to drop strictly contained prefixes before add element (#595) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/egress:v1.0.4 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.4 + +# components/egress 1.0.3 + +## What's New + +### ✨ Features +- add denied hostname webhook fanout (#406) +- add sandboxID within deny webhook payload (#427) + +### 📦 Misc +- install network tools, like ip (#427) +- refactor test by testify framework (#427) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/egress:v1.0.3 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.3 + +# components/egress 1.0.2 + +## What's New + +### ✨ Features +- add patch policy updates and somke coverage (#392) +- add nameserver exempt for direct DNS forwarding (#356) + +### 📦 Misc +- sync latest image for v-prefixed TAG (#331) +- Potential fix for code scanning alert no. 114: Workflow does not contain permissions (#278) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/egress:v1.0.2 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.2 + + +# components/egress 1.0.1 + +## What's New + +### ✨ Features +- Egress stage two for IP/CIDR rules, DoT/DoH block (#183) +- Egress stage three for dynamic IP insertion from DNS answers (#197) +- unified logger by internal package (#244) +- print build/compile info when start up (#245) + +### 📦 Misc +- chore(deps): bump golang.org/x/net from 0.26.0 to 0.38.0 in /components/egress (#192) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @dependabot + +--- +- Docker Hub: opensandbox/egress:v1.0.1 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.0.1 diff --git a/components/egress/build.sh b/components/egress/build.sh new file mode 100755 index 0000000..1ffecc9 --- /dev/null +++ b/components/egress/build.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -ex + +default_build_time() { + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + date -u -d "@${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || + date -u -r "${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" + else + date -u +"%Y-%m-%dT%H:%M:%SZ" + fi +} + +build_arg_if_set() { + local name="$1" + if [[ -n "${!name+x}" ]]; then + BUILD_ARGS+=(--build-arg "${name}=${!name}") + fi +} + +TAG=${TAG:-latest} +GHCR_REPO=${GHCR_REPO:-} +VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")} +GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")} +BUILD_TIME=${BUILD_TIME:-$(default_build_time)} +BUILD_METADATA_FILE=${BUILD_METADATA_FILE:-build/egress-image-metadata.json} +BUILD_ARGS=() +for name in GOFLAGS LDFLAGS CGO_ENABLED CC CXX CFLAGS CXXFLAGS CGO_CFLAGS CGO_CXXFLAGS CGO_LDFLAGS; do + build_arg_if_set "${name}" +done +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..") +cd "${REPO_ROOT}" +mkdir -p "$(dirname "${BUILD_METADATA_FILE}")" + +docker buildx rm egress-builder || true +docker buildx create --use --name egress-builder +docker buildx inspect --bootstrap +docker buildx ls + +IMAGE_TAGS=(-t opensandbox/egress:${TAG} -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:${TAG}) +LATEST_TAGS=() +if [[ -n "${GHCR_REPO}" ]]; then + IMAGE_TAGS+=(-t "${GHCR_REPO}/egress:${TAG}") +fi +if [[ "${TAG}" == v* ]]; then + LATEST_TAGS+=(-t opensandbox/egress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest) + if [[ -n "${GHCR_REPO}" ]]; then + LATEST_TAGS+=(-t "${GHCR_REPO}/egress:latest") + fi +fi + +docker buildx build \ + "${IMAGE_TAGS[@]}" \ + "${LATEST_TAGS[@]}" \ + -f components/egress/Dockerfile \ + "${BUILD_ARGS[@]}" \ + --build-arg VERSION="${VERSION}" \ + --build-arg GIT_COMMIT="${GIT_COMMIT}" \ + --build-arg BUILD_TIME="${BUILD_TIME}" \ + --platform linux/amd64,linux/arm64 \ + --metadata-file "${BUILD_METADATA_FILE}" \ + --push \ + . diff --git a/components/egress/cleanup_script_test.go b/components/egress/cleanup_script_test.go new file mode 100644 index 0000000..7c99895 --- /dev/null +++ b/components/egress/cleanup_script_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCleanupScriptRemovesNativeDNSRedirectFallbackTables(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + require.NoError(t, os.Mkdir(binDir, 0o755)) + logPath := filepath.Join(tmpDir, "nft.log") + orderPath := filepath.Join(tmpDir, "order.log") + + writeExecutable(t, filepath.Join(binDir, "nft"), `#!/bin/sh +printf '%s\n' "$*" >> "`+logPath+`" +cat >> "`+logPath+`" +printf 'nft\n' >> "`+orderPath+`" +exit 0 +`) + writeExecutable(t, filepath.Join(binDir, "iptables"), `#!/bin/sh +printf 'iptables\n' >> "`+orderPath+`" +exit 0 +`) + writeExecutable(t, filepath.Join(binDir, "ip6tables"), `#!/bin/sh +printf 'ip6tables\n' >> "`+orderPath+`" +exit 0 +`) + writeExecutable(t, filepath.Join(binDir, "pkill"), `#!/bin/sh +exit 0 +`) + + cmd := exec.Command("sh", "scripts/cleanup.sh") + cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + + require.NoError(t, err, string(out)) + logBytes, err := os.ReadFile(logPath) + require.NoError(t, err) + logText := string(logBytes) + require.Contains(t, logText, "delete table inet opensandbox_dns_redirect") + require.Contains(t, logText, "delete table ip opensandbox_dns_redirect") + require.Contains(t, logText, "delete table ip6 opensandbox_dns_redirect") + orderBytes, err := os.ReadFile(orderPath) + require.NoError(t, err) + order := strings.Split(strings.TrimSpace(string(orderBytes)), "\n") + require.NotEmpty(t, order) + require.Equal(t, "nft", order[0]) +} + +func writeExecutable(t *testing.T, path string, content string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o755)) +} diff --git a/components/egress/credential_vault_handler_test.go b/components/egress/credential_vault_handler_test.go new file mode 100644 index 0000000..bdb52fe --- /dev/null +++ b/components/egress/credential_vault_handler_test.go @@ -0,0 +1,307 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/credentialvault" + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/stretchr/testify/require" +) + +func testCredentialVaultPolicy(t *testing.T, raw string) *policy.NetworkPolicy { + t.Helper() + pol, err := policy.ParsePolicy(raw) + require.NoError(t, err) + return pol +} + +func testCredentialVaultRequest() credentialvault.CreateRequest { + return credentialvault.CreateRequest{ + Credentials: []credentialvault.Credential{ + { + Name: "gitlab-token", + Source: json.RawMessage(`{"type":"inline","value":"secret-token"}`), + }, + }, + Bindings: []credentialvault.Binding{ + { + Name: "gitlab-api", + Match: credentialvault.Match{ + Hosts: []string{"code.example.com"}, + Methods: []string{"GET"}, + Paths: []string{"/api/v8/*"}, + }, + Auth: credentialvault.Auth{ + Type: "apiKey", + Name: "PRIVATE-TOKEN", + Credential: "gitlab-token", + }, + }, + }, + } +} + +func TestCredentialVaultActiveTCPAlwaysForbidden(t *testing.T) { + store := credentialvault.NewStore(nil, func() bool { return true }) + pol := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + _, err := store.Create(testCredentialVaultRequest(), pol) + require.NoError(t, err) + srv := &policyServer{ + token: "public-egress-token", + credentialVault: store, + } + + req := httptest.NewRequest(http.MethodGet, "/credential-vault/_active", nil) + req.RemoteAddr = "127.0.0.1:4321" + req.Header.Set(constants.EgressAuthTokenHeader, "public-egress-token") + w := httptest.NewRecorder() + + srv.handleCredentialVaultSubresource(w, req) + + require.Equal(t, http.StatusForbidden, w.Result().StatusCode) + require.NotContains(t, w.Body.String(), "secret-token") +} + +func TestCredentialVaultActiveUnixSocketReturnsSnapshot(t *testing.T) { + store := credentialvault.NewStore(nil, func() bool { return true }) + pol := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + _, err := store.Create(testCredentialVaultRequest(), pol) + require.NoError(t, err) + srv := &policyServer{ + token: "public-egress-token", + credentialVault: store, + } + + tmpDir, err := os.MkdirTemp("/tmp", "opensandbox-active-*") + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tmpDir)) + }) + socketPath := filepath.Join(tmpDir, "credential-proxy", "active.sock") + _, cleanup, err := credentialvault.StartActiveSocketServer(srv.handleCredentialVaultActive, socketPath, -1) + require.NoError(t, err) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + require.NoError(t, cleanup(ctx)) + }) + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var dialer net.Dialer + return dialer.DialContext(ctx, "unix", socketPath) + }, + }, + } + resp, err := client.Get("http://credential-proxy/credential-vault/_active") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Contains(t, string(body), "secret-token") + require.Contains(t, string(body), "Private-Token") +} + +func TestCredentialVaultActiveBindingBlocksEgressPolicyRemoval(t *testing.T) { + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + store := credentialvault.NewStore(nil, func() bool { return true }) + _, err := store.Create(testCredentialVaultRequest(), initial) + require.NoError(t, err) + srv := &policyServer{ + proxy: proxy, + nft: nft, + enforcementMode: "dns+nft", + credentialVault: store, + } + + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(`["code.example.com"]`)) + w := httptest.NewRecorder() + srv.handlePolicy(w, req) + + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + require.Len(t, proxy.updated.Egress, 1) + require.Equal(t, 0, nft.calls) +} + +func TestCredentialVaultActiveBindingBlocksPolicyReset(t *testing.T) { + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + store := credentialvault.NewStore(nil, func() bool { return true }) + _, err := store.Create(testCredentialVaultRequest(), initial) + require.NoError(t, err) + srv := &policyServer{ + proxy: proxy, + nft: nft, + enforcementMode: "dns+nft", + credentialVault: store, + } + + req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader("")) + w := httptest.NewRecorder() + srv.handlePolicy(w, req) + + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + require.Contains(t, w.Body.String(), "credential vault policy validation") + require.Len(t, proxy.updated.Egress, 1) + require.Equal(t, 0, nft.calls) +} + +func TestCredentialVaultDeleteRequiresReady(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "") + srv := &policyServer{ + credentialVault: credentialvault.NewStore(nil, func() bool { return true }), + } + + req := httptest.NewRequest(http.MethodDelete, "/credential-vault", nil) + req.RemoteAddr = "127.0.0.1:4321" + w := httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode) + require.Contains(t, w.Body.String(), "transparent mitmproxy") +} + +func TestCredentialVaultWriteRequiresTLSOrLoopback(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "true") + t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft) + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + srv := &policyServer{ + proxy: &stubProxy{updated: initial}, + credentialVault: credentialvault.NewStore(nil, func() bool { return true }), + credentialVaultRequireTLS: true, + } + + req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`)) + req.RemoteAddr = "198.51.100.10:1234" + w := httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusUpgradeRequired, w.Result().StatusCode) + + req = httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`)) + req.RemoteAddr = "127.0.0.1:4321" + w = httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusCreated, w.Result().StatusCode) + + req = httptest.NewRequest(http.MethodDelete, "/credential-vault", nil) + req.RemoteAddr = "198.51.100.10:1234" + w = httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusUpgradeRequired, w.Result().StatusCode) + + req = httptest.NewRequest(http.MethodDelete, "/credential-vault", nil) + req.RemoteAddr = "127.0.0.1:4321" + w = httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusNoContent, w.Result().StatusCode) +} + +func TestCredentialVaultWriteAllowsForwardedProto(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "true") + t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft) + t.Setenv(constants.EnvCredentialVaultTrustedProxyCIDRs, "198.51.100.0/24") + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + srv := &policyServer{ + proxy: &stubProxy{updated: initial}, + credentialVault: credentialvault.NewStore(nil, func() bool { return true }), + credentialVaultRequireTLS: true, + } + + req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`)) + req.RemoteAddr = "198.51.100.10:1234" + req.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusCreated, w.Result().StatusCode) +} + +func TestCredentialVaultWriteRejectsForwardedProtoFromUntrustedPeer(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "true") + t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft) + t.Setenv(constants.EnvCredentialVaultTrustedProxyCIDRs, "203.0.113.0/24") + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + srv := &policyServer{ + proxy: &stubProxy{updated: initial}, + credentialVault: credentialvault.NewStore(nil, func() bool { return true }), + credentialVaultRequireTLS: true, + } + + req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`)) + req.RemoteAddr = "198.51.100.10:1234" + req.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusUpgradeRequired, w.Result().StatusCode) +} + +func TestCredentialVaultWriteSkipsTLSCheckByDefault(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "true") + t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft) + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + srv := &policyServer{ + proxy: &stubProxy{updated: initial}, + credentialVault: credentialvault.NewStore(nil, func() bool { return true }), + } + + req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`)) + req.RemoteAddr = "198.51.100.10:1234" + w := httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusCreated, w.Result().StatusCode) +} + +func TestCredentialVaultWriteRejectsDNSOnlyEnforcement(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "true") + t.Setenv(constants.EnvEgressMode, constants.PolicyDnsOnly) + initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + srv := &policyServer{ + proxy: &stubProxy{updated: initial}, + credentialVault: credentialvault.NewStore(nil, func() bool { return true }), + } + + req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`)) + req.RemoteAddr = "127.0.0.1:4321" + w := httptest.NewRecorder() + srv.handleCredentialVault(w, req) + + require.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode) + require.Contains(t, w.Body.String(), "dns+nft") +} diff --git a/components/egress/docs/benchmark.md b/components/egress/docs/benchmark.md new file mode 100644 index 0000000..bcfcd1f --- /dev/null +++ b/components/egress/docs/benchmark.md @@ -0,0 +1,84 @@ +# Egress benchmark + +**Prerequisites**: Docker, `curl` on the host. Domains: [`tests/hostname.txt`](../tests/hostname.txt) (one hostname per line; `#` and blank lines ignored). Run from `components/egress` or adjust paths. + +--- + +## 1. `bench-dns-nft.sh` + +**Compares**: plain **`curl`** container (**baseline**) → egress **`dns`** → egress **`dns+nft`**. Prints **Req/s**, **Avg**, **P50**, **P99**; percentages are vs **baseline**. + +### Run + +```bash +cd components/egress +./tests/bench-dns-nft.sh +``` + +Builds `opensandbox/egress:local` unless you set **`IMG=...`**. Optional: **`BENCH_SAMPLE_SIZE=n`** to use `n` random domains. + +### View results + +- **Terminal**: summary table at end. +- **Host `/tmp`**: `bench-e2e-baseline-total.txt`, `bench-e2e-dns-total.txt`, `bench-e2e-dns+nft-total.txt` (one **`time_total`** per line); `bench-e2e-{mode}-namelookup.txt`, `bench-e2e-{mode}-wall.txt`. + +--- + +## 2. `bench-mitm-overhead.sh` + +**Compares**: **`dns+nft`** without MITM vs **`dns+nft` + transparent mitmproxy**. Default **`BENCH_SCENARIOS=short,download`** — **`short`** = many HTTPS **HEAD**s; **`download`** = parallel **GET** to **`BENCH_DOWNLOAD_URL`** (default Cloudflare `__down` ~20 MiB). + +### Run + +```bash +cd components/egress +./tests/bench-mitm-overhead.sh +``` + +**`SKIP_BUILD=1`** skips image build; **`IMG`** is at the top of the script. One scenario only, e.g. **`BENCH_SCENARIOS=short`** or **`=download`**. + +### View results + +- **Terminal**: tables per scenario (latency / throughput vs no-MITM), plus **`E2E latency loss (avg time_total)`** in **ms/request** and **%**. +- **Host `/tmp`**: + - Latency artifacts: `bench-mitm-*-short-*.txt`, `*-download-*.tsv`, `*-wall.txt`, etc. + - **Container metrics** (always written): `bench-mitm-docker-stats-dns_nft.tsv`, `bench-mitm-docker-stats-dns_nft_mitm.tsv` — `unix_ts`, **`/proc/loadavg`** (load1/5/15, …), **`docker stats`** (CPUPerc, MemUsage, …). *`loadavg` inside the container often tracks the host; use for relative trends.* + +--- + +## 3. Reference baselines (example runs) + +Illustrative only — **same machine, same script**, not a SLA. **MITM** row = **`dns+nft` + transparent mitm**. + +### `BENCH_SCENARIOS=download` (parallel GET, ~20 MiB, 4 streams, 1 round, 1 s sampling) + +| Metric | `dns+nft` | + mitm | +|--------|-----------|--------| +| **CPUPerc** (docker) | Mostly **~2–5%**, max **~5.6%** | Often **~5–11%**, max **~10.9%** | +| **MemUsage** | **~9–18 MiB** | **~68–91 MiB** | +| **load1** | Up to **~0.23** | Spike **~0.66**, then **~0.4–0.6** | + +**Takeaway**: ~**2×** peak CPU% and ~**5×** RSS vs no MITM in this trace. + +### `BENCH_SCENARIOS=short` (HEAD storm; **sparse** rows if the phase is short) + +Run profile (sample): `10 rounds × 40 URLs × 1 inflight = 400 requests`. + +| Metric | `dns+nft` | + mitm | +|--------|-----------|--------| +| **Req/s** | **3.64** | **1.90** (**-47.6%**) | +| **Avg latency (time_total)** | **0.315 s** | **0.605 s** (**+91.9%**) | +| **P50 latency** | **0.136 s** | **0.143 s** (**+5.2%**) | +| **P99 latency** | **1.439 s** | **10.006 s** (**+595.2%**) | +| **E2E latency loss (avg)** | baseline | **+289.88 ms/request (+91.95%)** | + +| Metric | `dns+nft` | + mitm | +|--------|-----------|--------| +| **CPUPerc** | Hot sample **~132%** | Hot sample **~232%** | +| **MemUsage** | **~6–10 MiB** | **~58–88 MiB** | + +**`CPUPerc` > 100%** on multi-core is normal (container can use more than one core-equivalent per Docker’s metric). + +**Takeaway**: this sample shows clear request-side overhead from transparent MITM, about **+289.88 ms/request** on average with throughput dropping to about half. `P50` is close to baseline while `P99` grows sharply, indicating tail-latency amplification. With only **40 requests**, tail metrics are timing-sensitive; use more rounds or more domains for stable P99. + +CPU/memory trend remains consistent: peak CPU sample **~1.8×** (**232/132**), and RSS is much higher with mitmdump. For denser host/container telemetry, use longer runs or **`BENCH_DOCKER_STATS_INTERVAL=0.5`**. diff --git a/components/egress/docs/mitmproxy-transparent.md b/components/egress/docs/mitmproxy-transparent.md new file mode 100644 index 0000000..f30ff3f --- /dev/null +++ b/components/egress/docs/mitmproxy-transparent.md @@ -0,0 +1,238 @@ +# Python mitmproxy Transparent Mode (with Egress) + +Transparent mode starts `mitmdump --mode transparent` inside the sidecar and redirects local outbound `TCP 80/443` traffic to the mitmproxy listener via `iptables`. Its core benefits are: + +- **No application changes**: no need to set `HTTP_PROXY`; app traffic is intercepted transparently. +- **Observability and extensibility**: use mitm scripts for header injection, auditing, and debugging. +- **Controlled bypass**: use `ignore_hosts` for pass-through TLS (forward only, no decryption). + +Typical use case: add L7 visibility/processing at the egress boundary without changing the application networking stack. + +## Quick Setup (Minimum Working Config) + +### Prerequisites + +- Linux network namespace with `CAP_NET_ADMIN` in the container. +- `mitmdump` installed and `mitmproxy` user present in the image (included in official egress image). +- Client/system trusts the mitm root CA; otherwise HTTPS handshakes will fail. + +### Enable Transparent MITM + +```bash +export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true +``` + +By default, mitmproxy listens on `18081` and transparent redirect rules are set automatically. + +### Common Optional Settings + +```bash +# Optional: change listening port (default: 18081) +export OPENSANDBOX_EGRESS_MITMPROXY_PORT=18081 + +# Optional: load user-defined mitm addons (loaded after the system addon, comma-separated) +export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/path/to/your/addon.py,/path/to/another.py +``` + +To bypass decryption for selected domains, edit the baked-in +`components/egress/mitmproxy/config.yaml` and rebuild the image — see +"Static Configuration (config.yaml)" below. + +## Configuration Reference + +### Environment Variables (Per-Deployment Overrides) + +| Variable | Required | Purpose | Default | +|------|----------|------|--------| +| `OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT` | Yes | Enable transparent mitmproxy (`1/true/on`, etc.) | Disabled | +| `OPENSANDBOX_EGRESS_MITMPROXY_PORT` | No | mitmdump listen port; `iptables` redirects `80/443` here | `18081` | +| `OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT` | No | User mitm addon script paths (comma-separated); each is passed as `-s` and loaded after the system addon in order | Empty | +| `OPENSANDBOX_EGRESS_MITMPROXY_UPSTREAM_TRUST_DIR` | No | Trust directory for upstream TLS verification (OpenSSL style); overrides the config.yaml default | `/etc/ssl/certs` | +| `OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE` | No | Skip upstream TLS verification (`1/true/on`); use when clients connect by IP and SNI is unavailable | Disabled | + +Notes: + +- In transparent mode, mitmproxy generally recommends matching by IP/range; verify SNI/resolve behavior if using domain regex only. +- Before mitm, `iptables`, and CA export are ready, `GET /healthz` returns `503 (mitm not ready)` to prevent premature readiness. + +### Static Configuration (config.yaml) + +Fleet-wide, rarely-changing mitm options live in +`components/egress/mitmproxy/config.yaml`, baked into the image at +`/var/lib/mitmproxy/.mitmproxy/config.yaml` and auto-loaded by mitmdump. +This is the single source of truth for: + +- `mode` (`transparent`) — mitm default is `regular` +- `listen_host` (`127.0.0.1`) — mitm default is `0.0.0.0` +- `stream_large_bodies` (`1m`) — mitm default is unset (entire body buffered) +- `ssl_verify_upstream_trusted_confdir` (`/etc/ssl/certs`) — mitm default is unset; overridable per-deployment via env +- `connection_strategy` (`lazy`) — mitmproxy 10+ changed the default from `lazy` to `eager`; pinned explicitly to preserve the historical behavior of deferring upstream connections until the full request arrives +- `ignore_hosts` (`[]`) — matches the mitm default; kept in the file as a discoverable extension point for operators adding TLS pass-through entries + +Only deviations from the mitm built-in defaults are declared in `config.yaml` (the `ignore_hosts` entry is a discoverability exception; `connection_strategy` is a compatibility pin against the upstream default change). Other options that happen to match the default (`http2=true`, etc.) are omitted — the file is the diff against upstream defaults, not a full enumeration. + +Precedence: command-line `--set` (from env overrides) > `config.yaml` > mitmproxy built-in defaults. + +#### Overriding the built-in config.yaml + +There is no env var to point mitm at an alternate config file. Operators who need different static defaults (e.g. a different `ignore_hosts` list, `connection_strategy`, or `stream_large_bodies`) should pick one of the following: + +1. **Build a downstream image** that derives from the official egress image and replaces the file: + + ```dockerfile + FROM : + COPY my-config.yaml /var/lib/mitmproxy/.mitmproxy/config.yaml + RUN chown mitmproxy:mitmproxy /var/lib/mitmproxy/.mitmproxy/config.yaml \ + && chmod 0644 /var/lib/mitmproxy/.mitmproxy/config.yaml + ``` + + This is the recommended path because the override is version-controlled, reviewable, and reproducible. + +2. **Mount an override file at runtime** over the baked-in path. For Kubernetes, mount a `ConfigMap` as a file at `/var/lib/mitmproxy/.mitmproxy/config.yaml` (be aware that a `ConfigMap` file mount typically lands as read-only with the original UID, so verify the mitmproxy user can read it): + + ```yaml + volumeMounts: + - name: mitm-config + mountPath: /var/lib/mitmproxy/.mitmproxy/config.yaml + subPath: config.yaml + readOnly: true + volumes: + - name: mitm-config + configMap: + name: egress-mitm-config + defaultMode: 0644 + ``` + + Useful for staged rollouts or per-environment overrides without rebuilding the image. + +3. **Single-option escape hatch via env-driven `--set`** (already supported for the documented env variables above). This only works for options exposed via env and only for the single specific override; it cannot replace the whole file. + +Do not edit `config.yaml` inside a running container — the file lives in the container layer, edits are lost on restart, and the mitmproxy user has read-only access by design. + +## Common Configuration Templates + +### 1) Enable Transparent MITM Only + +```bash +export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true +``` + +### 2) System Addon (Always On) + +The bundled system addon at `/var/egress/mitmscripts/system.py` is shipped in the egress image and loaded automatically whenever transparent mode is enabled. It stays wire-transparent (no headers added or altered) and currently provides: + +- Forces streaming (`flow.response.stream = True`) for SSE (`text/event-stream`) and chunked responses, so each chunk is forwarded immediately instead of being buffered up to the `stream_large_bodies=1m` threshold (critical for LLM streaming UX). +- Redacts credential values from response headers. Response bodies are not rewritten by default. + +The system addon is always loaded and cannot be disabled via configuration. To override its behavior, supply user addons via `OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT` (comma-separated for multiple scripts); user addons are loaded after the system addon in the order given and may observe or override its hooks. + +### 3) Add User Addons Alongside the System Addon + +```bash +export OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true + +# Single addon +export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/path/to/your/addon.py + +# Multiple addons (comma-separated) +export OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT=/path/to/auth.py,/path/to/logging.py +``` + +User addons are loaded after the system addon (`-s system.py -s auth.py -s logging.py`), in the order given. Later addons observe and may override hooks from earlier ones. + +### 4) Bypass Decryption for Specific Domains (e.g. log upload) + +Edit `components/egress/mitmproxy/config.yaml` and append to `ignore_hosts`, +then rebuild the egress image: + +```yaml +ignore_hosts: + - '.*\.log\.aliyuncs\.com' +``` + +`ignore_hosts` means **no decryption**, not "completely bypass mitm process": +mitm still proxies the TCP connection, it just forwards bytes without +breaking TLS, and addons do not see request/response content. + +### 5) Use a Fixed CA (consistent fingerprint across replicas) + +If CA files already exist in `confdir`, mitmproxy reuses them instead of regenerating on each startup. Typical paths: + +- `/var/lib/mitmproxy/.mitmproxy/mitmproxy-ca.pem` (private key) +- `/var/lib/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem` (public cert) + +Ensure correct permissions (for example `mitmproxy:mitmproxy`, private key mode `600`). + +## Relationship with Policy/DNS + +Transparent mitmproxy does not automatically consume egress `NetworkPolicy`. Domain allow/deny behavior is still determined by DNS + (optional) nft rules. If L7 policy enforcement is needed, implement it in mitm scripts. + +## Implementation Notes and Limits + +Startup flow (high level): + +1. Start mitmdump as user `mitmproxy`, listening on `127.0.0.1:`. +2. Wait until the local listener is reachable. +3. Apply IPv4 `iptables` redirect rules: except loopback and mitmproxy-owned traffic, redirect outbound `80/443` to mitm port. + +Limits: + +- Currently IPv4 `iptables` only; IPv6 is not automatically handled. +- Non-Linux environments (for example local macOS runtime) are not supported for transparent mode. +- Full HTTPS decryption introduces CPU/memory and certificate trust overhead; benchmark before production rollout. + +## Process Supervisor (Crash Recovery) + +The egress sidecar includes a built-in supervisor that monitors the `mitmdump` child process and automatically restarts it on unexpected exits. + +### Restart behavior + +When `mitmdump` exits unexpectedly, the supervisor restarts it with **exponential backoff**: 1 s, 2 s, 4 s, ..., capped at **30 s**. Retries continue indefinitely until the process starts successfully or the egress sidecar itself shuts down. + +A successful restart requires two conditions: + +1. `mitmdump` process starts without error. +2. The listen port (`127.0.0.1:`) accepts TCP connections within 15 seconds. + +If the listener does not come up in time, the half-started process is gracefully terminated (SIGTERM → wait → SIGKILL) before the next attempt, so the port is released cleanly. + +### Generation tagging + +Each `mitmdump` launch is assigned a monotonically increasing **generation number**. When a process exits, the exit event carries the generation it was launched with. The supervisor compares this against the currently-live generation: + +- **Match**: the live process just died — trigger restart. +- **Mismatch**: a stale process from a previous failed attempt was reaped — ignore. + +This prevents restart storms where multiple rapid failures queue up cascading restart attempts. + +### Health gate integration + +When transparent mitmproxy is enabled: + +- `/healthz` returns **503** until the full mitm stack is ready (process started, listener up, iptables installed, CA exported). +- On crash, the health gate is set back to not-ready (503) immediately. +- After a successful restart and listener readiness, the health gate is restored. + +Kubernetes readiness probes that hit `/healthz` will stop routing traffic to the sandbox during the restart window. + +### Graceful shutdown + +When the egress sidecar receives `SIGTERM` or `SIGINT`: + +1. The supervisor watcher goroutine exits (context cancelled). +2. `iptables` transparent redirect rules are removed. +3. `mitmdump` receives `SIGTERM`; if it does not exit within 5 seconds, `SIGKILL` is sent. + +Any `OnExit` callbacks still blocked on the restart channel are unblocked via a dedicated shutdown channel, preventing goroutine leaks. + +### Observability + +All supervisor activity is logged with the `[mitmproxy]` prefix: + +| Log pattern | Meaning | +|-------------|---------| +| `mitmdump exited (gen=N): ; restarting...` | Live process crashed; restart initiated | +| `ignoring stale exit event (gen=N, current=M)` | Old generation reaped; no action needed | +| `restart attempt N failed; retrying in Xs` | Launch or listener wait failed; backing off | +| `mitmdump restarted (pid P, gen N, attempt M)` | Successful restart | +| `dropping exit event during shutdown` | Exit event discarded because egress is shutting down | diff --git a/components/egress/docs/opentelemetry.md b/components/egress/docs/opentelemetry.md new file mode 100644 index 0000000..d7779c5 --- /dev/null +++ b/components/egress/docs/opentelemetry.md @@ -0,0 +1,76 @@ +# OpenTelemetry Metrics (Current Egress Support) + +This page lists the OpenTelemetry metrics currently implemented in egress. + +## Meter + +- `opensandbox/egress` + +## Metrics + +| Metric | Type | Unit | Meaning | +|---|---|---|---| +| `egress.dns.query.duration` | Histogram | `s` | Upstream DNS forward latency (recorded for allowed queries). | +| `egress.policy.denied_total` | Counter | - | Number of DNS queries denied by policy. | +| `egress.nftables.rules.count` | Observable Gauge | `{element}` | Approximate policy size after last successful static apply. | +| `egress.nftables.updates.count` | Counter | - | Number of successful nftables updates (static apply + dynamic IP add). | +| `egress.system.memory.usage_bytes` | Observable Gauge | `By` | System memory used bytes (Linux: gopsutil; non-Linux build: `0`). | +| `egress.system.cpu.utilization` | Observable Gauge | `1` | CPU busy ratio in `[0,1]` (Linux: gopsutil; non-Linux build: `0`). | + +## Shared Attributes + +All egress metrics may include shared attributes: + +- `sandbox_id` from `OPENSANDBOX_EGRESS_SANDBOX_ID` (when set) +- extra key/value attributes from `OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS` (when set) + +## OTEL Endpoint Configuration + +Metric export is enabled only when at least one OTLP endpoint is set. + +- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (preferred) +- `OTEL_EXPORTER_OTLP_ENDPOINT` (fallback) + +If both are unset, egress keeps metrics local (no OTLP export). + +### Minimal Example + +```bash +export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://otel-collector:4318" +``` + +### Service Name + +`service.name` is set by egress code as `opensandbox-egress-`. + +## Structured logs (JSON) + +Egress structured logs are emitted by zap (typically to stdout). OTLP log export is not implemented in-tree. + +### Common fields + +- `sandbox_id` is included when `OPENSANDBOX_EGRESS_SANDBOX_ID` is set. +- key/value pairs from `OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS` are merged into the root logger. +- `opensandbox.event` identifies the event family. + +### Outbound DNS logs + +- `opensandbox.event=egress.outbound` +- emitted on allow-path DNS handling (success or forward error) +- common payload keys: + - `target.host` (normalized query name) + - `target.ips` (resolved A/AAAA addresses, when present) + - `peer` (IP-only destination path) + - `error` (forward failure message) + +### Policy lifecycle logs + +- `opensandbox.event=egress.loaded` (initial effective policy loaded) +- `opensandbox.event=egress.updated` (policy update applied) +- `opensandbox.event=egress.update_failed` (policy update failed) + +Common policy fields: + +- `egress.default` (`allow` / `deny`) +- `rules` (rule summary; for `egress.updated`, reflects current request body semantics) +- `error` (present for `egress.update_failed`) diff --git a/components/egress/go.mod b/components/egress/go.mod new file mode 100644 index 0000000..4537807 --- /dev/null +++ b/components/egress/go.mod @@ -0,0 +1,53 @@ +module github.com/alibaba/opensandbox/egress + +go 1.25.0 + +require ( + github.com/alibaba/opensandbox/internal v0.0.0 + github.com/miekg/dns v1.1.61 + github.com/shirou/gopsutil v3.21.11+incompatible + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.uber.org/automaxprocs v1.6.0 + golang.org/x/sys v0.42.0 + k8s.io/apimachinery v0.34.2 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect +) + +replace github.com/alibaba/opensandbox/internal => ../internal diff --git a/components/egress/go.sum b/components/egress/go.sum new file mode 100644 index 0000000..12213c5 --- /dev/null +++ b/components/egress/go.sum @@ -0,0 +1,103 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= +github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/components/egress/hooks/doc.go b/components/egress/hooks/doc.go new file mode 100644 index 0000000..8713419 --- /dev/null +++ b/components/egress/hooks/doc.go @@ -0,0 +1,17 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package hooks: side-effect import target. Add files here whose init() registers startup.Register/RegisterFunc; +// hooks run in startup.RunPost after the MITM/iptables path in main, before blocking on signal. +package hooks diff --git a/components/egress/main.go b/components/egress/main.go new file mode 100644 index 0000000..347e878 --- /dev/null +++ b/components/egress/main.go @@ -0,0 +1,179 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "net/netip" + "os" + "os/signal" + "strings" + "syscall" + "time" + + _ "github.com/alibaba/opensandbox/egress/hooks" + _ "github.com/alibaba/opensandbox/internal/safego" + _ "go.uber.org/automaxprocs/maxprocs" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" + "github.com/alibaba/opensandbox/egress/pkg/events" + "github.com/alibaba/opensandbox/egress/pkg/iptables" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/mitmproxy" + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/alibaba/opensandbox/egress/pkg/startup" + "github.com/alibaba/opensandbox/egress/pkg/telemetry" + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/alibaba/opensandbox/internal/safego" + "github.com/alibaba/opensandbox/internal/version" +) + +func main() { + version.EchoVersion("OpenSandbox Egress") + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + ctx = withLogger(ctx) + defer log.Logger.Sync() + + otelShutdown, err := telemetry.Init(ctx) + if err != nil { + log.Warnf("OpenTelemetry metrics disabled (continuing without OTLP): %v", err) + otelShutdown = nil + } + if otelShutdown != nil { + defer func() { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + _ = otelShutdown(shutdownCtx) + }() + } + + initialRules, _, err := policy.LoadInitialPolicyDetailed(os.Getenv(constants.EnvEgressPolicyFile), constants.EnvEgressRules) + if err != nil { + log.Fatalf("failed to load initial egress policy: %v", err) + } + logEgressLoaded(initialRules) + + alwaysDeny, alwaysAllow, err := policy.LoadAlwaysRuleFiles() + if err != nil { + log.Fatalf("failed to load always allow/deny rule files: %v", err) + } + + allowIPs := allowIps() + mode := parseMode() + log.Infof("enforcement mode: %s", mode) + nftMgr := createNftManager(mode) + proxy, err := dnsproxy.New(initialRules, "", alwaysDeny, alwaysAllow) + if err != nil { + log.Fatalf("failed to init dns proxy: %v", err) + } + if err := proxy.Start(ctx); err != nil { + log.Fatalf("failed to start dns proxy: %v", err) + } + log.Infof("dns proxy started on 127.0.0.1:15353") + + logSkipPatterns, err := policy.LoadLogSkipFile() + if err != nil { + log.Fatalf("failed to load outbound log skip file: %v", err) + } + if len(logSkipPatterns) > 0 { + proxy.SetLogSkip(logSkipPatterns) + log.Infof("loaded %d outbound log skip pattern(s) from /var/egress/rules/log_skip.always", len(logSkipPatterns)) + } + + if blockWebhookURL := strings.TrimSpace(os.Getenv(constants.EnvBlockedWebhook)); blockWebhookURL != "" { + blockedBroadcaster := events.NewBroadcaster(ctx, events.BroadcasterConfig{QueueSize: 256}) + blockedBroadcaster.AddSubscriber(events.NewWebhookSubscriber(blockWebhookURL)) + proxy.SetBlockedBroadcaster(blockedBroadcaster) + defer blockedBroadcaster.Close() + log.Infof("denied hostname webhook enabled") + } + + exemptDst := dnsproxy.ParseNameserverExemptList() + if len(exemptDst) > 0 { + log.Infof("nameserver exempt list: %v (proxy upstream in this list will not set SO_MARK)", exemptDst) + } + if err := iptables.SetupRedirect(15353, exemptDst); err != nil { + log.Fatalf("failed to install iptables redirect: %v", err) + } + log.Infof("iptables redirect configured (OUTPUT 53 -> 15353) with SO_MARK bypass for proxy upstream traffic") + + setupNft(ctx, nftMgr, initialRules, proxy, allowIPs, alwaysDeny, alwaysAllow) + + httpAddr := envOrDefault(constants.EnvEgressHTTPAddr, constants.DefaultEgressServerAddr) + mitmGate := mitmproxy.NewHealthGate() + policySrv, err := startPolicyServer(proxy, nftMgr, mode, httpAddr, os.Getenv(constants.EnvEgressToken), allowIPs, os.Getenv(constants.EnvEgressPolicyFile), alwaysDeny, alwaysAllow, mitmGate) + if err != nil { + log.Fatalf("failed to start policy server: %v", err) + } + log.Infof("policy server listening on %s (POST /policy)", httpAddr) + + mitm, err := startMitmproxyTransparentIfEnabled() + if err != nil { + log.Fatalf("mitmproxy transparent: %v", err) + } + mitmGate.MarkStackReady() + if mitm != nil { + mitm.watchMitmproxy(ctx, mitmGate) + } + + if err := startup.RunPost(ctx); err != nil { + log.Errorf("startup hooks (post) error: %v", err) + } + + waitForShutdown(ctx, proxy, policySrv, exemptDst, nftMgr, mitm) +} + +func withLogger(ctx context.Context) context.Context { + level := envOrDefault(constants.EnvEgressLogLevel, "info") + cfg := slogger.Config{Level: level} + base := slogger.MustNew(cfg) + // Baseline log fields (e.g. sandbox_id, OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS) for every line. + if extra := telemetry.EgressLogFields(); len(extra) > 0 { + base = base.With(extra...) + } + logger := base.Named("opensandbox.egress") + safego.InitPanicLogger(ctx, logger) + return log.WithLogger(ctx, logger) +} + +func envOrDefault(key, defaultVal string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return defaultVal +} + +func containsAddr(addrs []netip.Addr, a netip.Addr) bool { + for _, x := range addrs { + if x == a { + return true + } + } + return false +} + +func parseMode() string { + raw := os.Getenv(constants.EnvEgressMode) + normalized, err := constants.ParseEgressMode(raw) + if err != nil { + log.Warnf("invalid %s=%q: %v; falling back to %s", constants.EnvEgressMode, raw, err, constants.PolicyDnsOnly) + return constants.PolicyDnsOnly + } + return normalized +} diff --git a/components/egress/mitmproxy/config.yaml b/components/egress/mitmproxy/config.yaml new file mode 100644 index 0000000..d7c7794 --- /dev/null +++ b/components/egress/mitmproxy/config.yaml @@ -0,0 +1,44 @@ +# Static mitmproxy options that override mitm built-in defaults for the +# OpenSandbox egress sidecar. Loaded automatically by mitmdump from +# /var/lib/mitmproxy/.mitmproxy/config.yaml. +# +# Only deviations from mitm defaults are listed here. Options that +# happen to match the mitm default (http2=true, etc.) are intentionally +# omitted — the file is meant to be the diff against upstream defaults, +# not a full enumeration. Two intentional exceptions to this rule: +# ignore_hosts (kept as a discoverable extension point) and +# connection_strategy (mitmproxy 10+ changed the default from lazy to +# eager; we pin lazy explicitly to preserve the historical behavior). +# +# Per-deployment overrides remain env-driven and applied as --set by +# launch.go. Precedence: command-line --set > this file > mitm defaults. + +mode: + - transparent + +# mitm default changed from lazy to eager in mitmproxy 10+. We pin +# lazy explicitly: upstream connections are deferred until the full +# request arrives, avoiding unnecessary upstream opens for blocked +# or filtered requests. +connection_strategy: lazy + +# mitm default 0.0.0.0; transparent mode must only accept loopback inside +# the netns (iptables REDIRECT pushes outbound traffic here, and exposing +# mitm on the LAN would route any inbound connection through it). +listen_host: 127.0.0.1 + +# mitm default None (whole body buffered in memory). 1m bounds RSS for +# the allow path; chunked / SSE responses are forced to stream regardless +# by the system addon's responseheaders hook. +stream_large_bodies: 1m + +# mitm default None (Python certifi bundle). Match the OS trust store so +# private-CA additions land where mitm reads them. +ssl_verify_upstream_trusted_confdir: /etc/ssl/certs + +# Hosts (Python regex) for TLS pass-through: mitm forwards bytes without +# decryption and addons do not see request/response content. Empty matches +# the mitm default; kept here as a discoverable extension point. Append +# entries here rather than passing --set on the command line, because +# --set on a list option REPLACES the entire list. +ignore_hosts: [] diff --git a/components/egress/mitmproxy_transparent.go b/components/egress/mitmproxy_transparent.go new file mode 100644 index 0000000..6bc71d0 --- /dev/null +++ b/components/egress/mitmproxy_transparent.go @@ -0,0 +1,257 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/iptables" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/mitmproxy" + "github.com/alibaba/opensandbox/internal/safego" +) + +// exitEvent carries an OnExit notification tagged with the generation of the +// mitmdump process that produced it. Generation tagging lets the watcher tell +// "the currently-running mitmdump just died" apart from "a half-launched +// attempt we already killed during a retry storm just finished reaping". +type exitEvent struct { + gen uint64 + err error +} + +type mitmTransparent struct { + mu sync.Mutex + running *mitmproxy.Running + currentGen uint64 // generation of the mitmdump currently considered live + port int + uid uint32 + cfg mitmproxy.Config // OnExit must NOT be set here; built per-Launch + nextGen uint64 // atomic; monotonic gen counter handed to each Launch + restartCh chan exitEvent + shutdownCh chan struct{} // closed by watchMitmproxy on ctx cancel; lets OnExit unblock during shutdown +} + +func (m *mitmTransparent) getRunning() *mitmproxy.Running { + m.mu.Lock() + defer m.mu.Unlock() + return m.running +} + +func (m *mitmTransparent) setRunning(r *mitmproxy.Running, gen uint64) { + m.mu.Lock() + defer m.mu.Unlock() + m.running = r + m.currentGen = gen +} + +func (m *mitmTransparent) getCurrentGen() uint64 { + m.mu.Lock() + defer m.mu.Unlock() + return m.currentGen +} + +// launchTagged starts mitmdump with an OnExit closure that publishes the death +// of this specific process (identified by gen) into restartCh. +// +// The send is blocking with shutdownCh as the only escape: dropping an exit +// event while the watcher is still running can leave egress in a silent dead +// state (the watcher would never see the death and never trigger a restart). +// Stale events from killed half-launched attempts are still cheap to discard +// downstream via the gen check in watchMitmproxy; we just must not lose them +// in transit. Shutdown is the only legitimate reason to give up on a send, +// and we log a warning when that happens so the drop is observable. +func launchTagged(cfg mitmproxy.Config, restartCh chan<- exitEvent, shutdownCh <-chan struct{}, gen uint64) (*mitmproxy.Running, error) { + cfg.OnExit = func(err error) { + select { + case restartCh <- exitEvent{gen: gen, err: err}: + case <-shutdownCh: + log.Warnf("[mitmproxy] dropping exit event during shutdown (gen=%d): %v", gen, err) + } + } + return mitmproxy.Launch(cfg) +} + +// startMitmproxyTransparentIfEnabled starts mitmdump in transparent mode, waits for the listener, and installs OUTPUT REDIRECT, then syncs the CA. +func startMitmproxyTransparentIfEnabled() (*mitmTransparent, error) { + if !constants.IsTruthy(os.Getenv(constants.EnvMitmproxyTransparent)) { + return nil, nil + } + + mpPort := constants.EnvIntOrDefault(constants.EnvMitmproxyPort, constants.DefaultMitmproxyPort) + mpUID, _, mpHome, err := mitmproxy.LookupUser(mitmproxy.RunAsUser) + if err != nil { + return nil, fmt.Errorf("lookup user %q: %w (ensure this user exists in the image)", mitmproxy.RunAsUser, err) + } + + cfg := mitmproxy.Config{ + ListenPort: mpPort, + UserName: mitmproxy.RunAsUser, + ScriptPaths: parseScriptPaths(os.Getenv(constants.EnvMitmproxyScript)), + } + // Buffer absorbs OnExit events from a retry storm so OnExit goroutines + // don't all park waiting for the watcher to drain. Correctness does not + // depend on the size: launchTagged uses a blocking send with shutdownCh + // as the only escape, so events cannot be silently dropped while the + // watcher is alive. + restartCh := make(chan exitEvent, 64) + shutdownCh := make(chan struct{}) + const initialGen uint64 = 1 + running, err := launchTagged(cfg, restartCh, shutdownCh, initialGen) + if err != nil { + return nil, fmt.Errorf("start mitmdump: %w", err) + } + + waitAddr := fmt.Sprintf("127.0.0.1:%d", mpPort) + if err := mitmproxy.WaitListenPort(waitAddr, 15*time.Second); err != nil { + return nil, fmt.Errorf("wait listen %s: %w", waitAddr, err) + } + if err := iptables.SetupTransparentHTTP(mpPort, mpUID); err != nil { + return nil, fmt.Errorf("iptables transparent: %w", err) + } + log.Infof("mitmproxy: transparent intercept active (OUTPUT tcp 80,443 -> %d; trust mitm CA in clients)", mpPort) + + if err := mitmproxy.SyncRootCA("", mpHome); err != nil { + return nil, fmt.Errorf("mitm CA export: %w", err) + } + return &mitmTransparent{ + running: running, + currentGen: initialGen, + port: mpPort, + uid: mpUID, + cfg: cfg, + nextGen: initialGen, + restartCh: restartCh, + shutdownCh: shutdownCh, + }, nil +} + +// watchMitmproxy monitors mitmdump for unexpected exits, logs the error, and restarts it. +// Must be called after startMitmproxyTransparentIfEnabled. +func (m *mitmTransparent) watchMitmproxy(ctx context.Context, gate *mitmproxy.HealthGate) { + // Closing shutdownCh on ctx cancel unblocks any OnExit closures that are + // parked on the (now-unread) restartCh send so they don't leak past + // shutdown. + safego.Go(func() { + <-ctx.Done() + close(m.shutdownCh) + }) + safego.Go(func() { + for { + select { + case ev := <-m.restartCh: + select { + case <-ctx.Done(): + return + default: + } + cur := m.getCurrentGen() + if ev.gen != cur { + // Stale event: a previous half-launched attempt that we + // killed is just now being reaped. The currently-live + // mitmdump is unaffected; ignore and keep watching. + log.Infof("[mitmproxy] ignoring stale exit event (gen=%d, current=%d): %v", ev.gen, cur, ev.err) + continue + } + + log.Errorf("[mitmproxy] mitmdump exited (gen=%d): %v; restarting...", ev.gen, ev.err) + gate.SetReady(false) + m.restartWithBackoff(ctx, gate) + + case <-ctx.Done(): + return + } + } + }) +} + +// restartWithBackoff retries mitmdump launch indefinitely with exponential backoff +// (1s, 2s, 4s, ..., capped at 30s) until it succeeds or ctx is cancelled. +// Transient OOM / resource pressure must not leave egress in a permanent dead state. +// +// Each attempt is tagged with a fresh generation; setRunning publishes that +// generation as the "live" one. Exit events for older (killed) generations are +// filtered out by watchMitmproxy, so we do NOT drain restartCh here -- doing +// so could swallow a real death of the freshly-restarted mitmdump. +func (m *mitmTransparent) restartWithBackoff(ctx context.Context, gate *mitmproxy.HealthGate) { + const ( + initialBackoff = time.Second + maxBackoff = 30 * time.Second + ) + backoff := initialBackoff + waitAddr := fmt.Sprintf("127.0.0.1:%d", m.cfg.ListenPort) + + for attempt := 1; ; attempt++ { + select { + case <-ctx.Done(): + return + default: + } + + gen := atomic.AddUint64(&m.nextGen, 1) + newRunning, launchErr := launchTagged(m.cfg, m.restartCh, m.shutdownCh, gen) + if launchErr == nil { + if waitErr := mitmproxy.WaitListenPort(waitAddr, 15*time.Second); waitErr == nil { + m.setRunning(newRunning, gen) + gate.SetReady(true) + log.Infof("[mitmproxy] mitmdump restarted (pid %d, gen %d, attempt %d)", newRunning.Cmd.Process.Pid, gen, attempt) + return + } else { + log.Errorf("[mitmproxy] restart attempt %d (gen %d): wait listen %s: %v", attempt, gen, waitAddr, waitErr) + // GracefulShutdown SIGTERMs then SIGKILLs and waits for reap, so + // the listen port is released before the next attempt's Launch + // races to bind it. Direct Process.Kill returns immediately and + // can cause spurious WaitListenPort failures on port contention. + mitmproxy.GracefulShutdown(newRunning, time.Second) + } + } else { + log.Errorf("[mitmproxy] restart attempt %d (gen %d): launch failed: %v", attempt, gen, launchErr) + } + + log.Warnf("[mitmproxy] restart attempt %d failed; retrying in %s", attempt, backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +func parseScriptPaths(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} diff --git a/components/egress/mitmscripts/system.py b/components/egress/mitmscripts/system.py new file mode 100644 index 0000000..f25853f --- /dev/null +++ b/components/egress/mitmscripts/system.py @@ -0,0 +1,577 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# OpenSandbox egress system addon. +# +# Always loaded by the egress mitmproxy launcher. Stays transparent on the +# wire (does not add or alter headers that would reveal the proxy to peers). +# +# Behavior: +# 1. Forces streaming for SSE / chunked responses so each chunk is forwarded +# immediately, bypassing the stream_large_bodies=1m buffer set in config.yaml +# (which otherwise stalls LLM-style small-chunk streams). +# 2. Acts as Credential Proxy when the egress sidecar has an active +# Credential Vault revision. The active revision is stored in the Go +# sidecar process and read over an egress-container-private Unix socket. +# Credential values are not logged, and response header values containing +# credentials are redacted. Response bodies are not rewritten by default. +# 3. Implements SNI-aware ignore_hosts for transparent mode. mitmproxy's +# built-in ignore_hosts check in transparent mode matches against the +# destination IP first; the SNI hostname is only available inside the TLS +# ClientHello, which arrives after the initial check. This addon re-checks +# the same ignore_hosts patterns against the SNI hostname at the +# tls_clienthello layer and sets ignore_connection=True when a match is +# found, ensuring domain-based TLS pass-through works reliably. +# +# User-defined addons can be loaded alongside this script via +# OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT (comma-separated for multiple scripts). +from __future__ import annotations + +import http.client as http_client +import json +import os +import re +import socket +import time +from typing import Any +from urllib.parse import quote, quote_plus, unquote + +from mitmproxy import ctx, http +from mitmproxy.tls import ClientHelloData + + +CREDENTIAL_PROXY_SOCKET_ENV = "OPENSANDBOX_CREDENTIAL_PROXY_SOCKET" +DEFAULT_CREDENTIAL_PROXY_SOCKET = "/run/opensandbox/credential-proxy/active.sock" +ACTIVE_VAULT_PATH = "/credential-vault/_active" +VAULT_CACHE_TTL_SECONDS = 0.5 +FLOW_REDACTIONS_KEY = "opensandbox_credential_redactions" +HEADER_SUBSTITUTION_DENYLIST = { + "host", + "content-length", + "transfer-encoding", + "connection", + "upgrade", + "te", + "trailer", + "proxy-authorization", + "proxy-authenticate", + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", +} + + +class ActiveVault: + def __init__( + self, + revision: int, + bindings: list[dict[str, Any]], + redactions: list[str], + ) -> None: + self.revision = revision + self.bindings = bindings + self.redactions = redactions + + +_vault_cache: ActiveVault | None = None +_vault_cache_loaded_at = 0.0 + + +class UnixSocketHTTPConnection(http_client.HTTPConnection): + def __init__(self, socket_path: str, timeout: float) -> None: + super().__init__("credential-proxy", timeout=timeout) + self.socket_path = socket_path + + def connect(self) -> None: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(self.socket_path) + self.sock = sock + + +def tls_clienthello(data: ClientHelloData) -> None: + """Re-check ignore_hosts patterns against SNI hostname. + + In transparent mode, mitmproxy checks ignore_hosts against the + destination IP:port before the TLS handshake. If the check fails at + that stage (SNI not yet available), we get a second chance here with + the actual hostname from the ClientHello SNI extension. + """ + sni = data.client_hello.sni + if not sni: + return + + patterns = ctx.options.ignore_hosts + if not patterns: + return + + for pattern in patterns: + try: + if re.search(pattern, sni): + data.ignore_connection = True + return + except re.error: + pass + + +def _load_active_vault() -> ActiveVault | None: + global _vault_cache, _vault_cache_loaded_at + now = time.monotonic() + if _vault_cache is not None and now - _vault_cache_loaded_at < VAULT_CACHE_TTL_SECONDS: + return _vault_cache + + socket_path = ( + os.environ.get(CREDENTIAL_PROXY_SOCKET_ENV, "").strip() + or DEFAULT_CREDENTIAL_PROXY_SOCKET + ) + connection = UnixSocketHTTPConnection(socket_path, timeout=0.25) + try: + connection.request("GET", ACTIVE_VAULT_PATH) + response = connection.getresponse() + body = response.read() + if response.status == 404: + _vault_cache = None + _vault_cache_loaded_at = now + return None + if response.status != 200: + ctx.log.warn( + f"credential proxy: active vault lookup failed with HTTP {response.status}" + ) + _vault_cache = None + _vault_cache_loaded_at = now + return None + payload = json.loads(body.decode("utf-8")) + except Exception as exc: # noqa: BLE001 - mitm addon must not crash traffic handling + ctx.log.warn(f"credential proxy: active vault lookup failed: {exc}") + _vault_cache = None + _vault_cache_loaded_at = now + return None + finally: + connection.close() + + bindings = payload.get("bindings") or [] + redactions = [v for v in (payload.get("redactions") or []) if isinstance(v, str) and v] + _vault_cache = ActiveVault( + revision=int(payload.get("revision") or 0), + bindings=bindings, + redactions=redactions, + ) + _vault_cache_loaded_at = now + return _vault_cache + + +def _request_host(flow: http.HTTPFlow) -> str: + host = flow.request.pretty_host or flow.request.host or "" + return host.rstrip(".").lower() + + +def _request_port(flow: http.HTTPFlow) -> int: + if flow.request.port: + return int(flow.request.port) + return 443 if flow.request.scheme == "https" else 80 + + +def _request_path(flow: http.HTTPFlow) -> str: + path = flow.request.path or "/" + return path.split("?", 1)[0] or "/" + + +_DOT_SEGMENT_RE = re.compile(r"/\.\.(/|$)") + + +def _path_is_ambiguous(raw_path: str) -> bool: + """Return True if the raw request path contains ambiguous segments. + + Legitimate HTTP clients resolve dot segments before sending. Raw ``..`` + or percent-encoded separators on the wire indicate an attempt to confuse + path-based authorization checks because the canonical path seen by the + upstream server may differ from the raw prefix matched here. + """ + path = raw_path.split("?", 1)[0] + + # Only match ``..`` as a complete path segment (/../ or trailing /..). + if _DOT_SEGMENT_RE.search(path): + return True + + # Iteratively decode to catch nested encodings like %252e%252e or %252f. + decoded = path + for _ in range(10): + lower = decoded.lower() + if "%2f" in lower or "%5c" in lower: + return True + if "\\" in decoded: + return True + next_decoded = unquote(decoded) + if next_decoded == decoded: + break + decoded = next_decoded + if _DOT_SEGMENT_RE.search(decoded): + return True + + return False + + +def _host_matches(host: str, pattern: str) -> tuple[bool, int]: + pattern = pattern.rstrip(".").lower() + if pattern.startswith("*."): + suffix = pattern[1:] + apex = pattern[2:] + return host.endswith(suffix) and host != apex, 1 + return host == pattern, 2 + + +def _path_matches(path: str, pattern: str) -> bool: + if pattern.endswith("*"): + return path.startswith(pattern[:-1]) + return path == pattern + + +def _binding_matches(flow: http.HTTPFlow, binding: dict[str, Any]) -> tuple[bool, int]: + match = binding.get("match") or {} + scheme = (flow.request.scheme or "").lower() + host = _request_host(flow) + port = _request_port(flow) + method = (flow.request.method or "").upper() + path = _request_path(flow) + + schemes = match.get("schemes") or ["https"] + if scheme not in schemes: + return False, 0 + canonical_port = 443 if scheme == "https" else 80 + if port != canonical_port: + return False, 0 + if method not in [m.upper() for m in (match.get("methods") or ["GET", "POST", "PUT", "PATCH", "DELETE"])]: + return False, 0 + if not any(_path_matches(path, p) for p in (match.get("paths") or ["/*"])): + return False, 0 + + best_precedence = 0 + for pattern in match.get("hosts") or []: + ok, precedence = _host_matches(host, pattern) + if ok and precedence > best_precedence: + best_precedence = precedence + return best_precedence > 0, best_precedence + + +def _select_binding(flow: http.HTTPFlow, vault: ActiveVault) -> dict[str, Any] | None: + matches: list[tuple[int, dict[str, Any]]] = [] + for binding in vault.bindings: + ok, precedence = _binding_matches(flow, binding) + if ok: + matches.append((precedence, binding)) + if not matches: + return None + + highest = max(precedence for precedence, _ in matches) + selected = [binding for precedence, binding in matches if precedence == highest] + if len(selected) != 1: + flow.response = http.Response.make( + 403, + b"credential binding ambiguous\n", + {"content-type": "text/plain"}, + ) + ctx.log.warn( + "credential proxy: ambiguous binding match for " + f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}" + ) + return None + return selected[0] + + +def _split_path_query(raw_path: str) -> tuple[str, str | None]: + if "?" not in raw_path: + return raw_path or "/", None + path, query = raw_path.split("?", 1) + return path or "/", query + + +def _request_body_bytes(flow: http.HTTPFlow) -> bytes | None: + body = getattr(flow.request, "raw_content", None) + if body is None: + body = getattr(flow.request, "content", None) + if body is None: + return None + if isinstance(body, str): + return body.encode("utf-8") + return body + + +def _set_request_body_bytes(flow: http.HTTPFlow, body: bytes) -> None: + flow.request.content = body + if "transfer-encoding" in flow.request.headers: + del flow.request.headers["transfer-encoding"] + flow.request.headers["content-length"] = str(len(body)) + + +def _encoded_substitution_value(value: str, surface: str, content_type: str = "") -> str: + if surface in {"path", "query"}: + return quote(value, safe="") + if surface == "body": + normalized_type = content_type.split(";", 1)[0].strip().lower() + if normalized_type == "application/json" or normalized_type.endswith("+json"): + return json.dumps(value)[1:-1] + if normalized_type == "application/x-www-form-urlencoded": + return quote_plus(value, safe="") + return value + + +def _replace_literals_once(text: str, replacements: list[tuple[str, str]]) -> tuple[str, list[int]]: + # Apply replacements against the original text so inserted credential values + # are never scanned again for later placeholders. + if not replacements: + return text, [] + + parts: list[str] = [] + applied: list[int] = [] + applied_set: set[int] = set() + i = 0 + while i < len(text): + selected_index = -1 + selected_placeholder = "" + selected_replacement = "" + for index, (placeholder, replacement) in enumerate(replacements): + if len(placeholder) <= len(selected_placeholder): + continue + if text.startswith(placeholder, i): + selected_index = index + selected_placeholder = placeholder + selected_replacement = replacement + if selected_index >= 0: + parts.append(selected_replacement) + if selected_index not in applied_set: + applied.append(selected_index) + applied_set.add(selected_index) + i += len(selected_placeholder) + continue + parts.append(text[i]) + i += 1 + + if not applied: + return text, [] + return "".join(parts), applied + + +def _substitution_replacements( + substitutions: list[dict[str, Any]], surface: str, content_type: str = "" +) -> list[tuple[str, str]]: + replacements: list[tuple[str, str]] = [] + for substitution in substitutions: + placeholder = substitution.get("placeholder") + value = substitution.get("value") + surfaces = substitution.get("in") or [] + if not placeholder or value is None or surface not in surfaces: + continue + replacements.append( + ( + str(placeholder), + _encoded_substitution_value(str(value), surface, content_type), + ) + ) + return replacements + + +def _apply_path_query_substitutions( + flow: http.HTTPFlow, + substitutions: list[dict[str, Any]], +) -> list[str]: + raw_path = flow.request.path or "/" + path_part, query_part = _split_path_query(raw_path) + path_changed = False + query_changed = False + applied: list[str] = [] + + path_part, path_applied = _replace_literals_once( + path_part, _substitution_replacements(substitutions, "path") + ) + if path_applied: + path_changed = True + applied.extend("path" for _ in path_applied) + if query_part is not None: + query_part, query_applied = _replace_literals_once( + query_part, _substitution_replacements(substitutions, "query") + ) + if query_applied: + query_changed = True + applied.extend("query" for _ in query_applied) + + if path_changed: + candidate_path = path_part + if query_part is not None: + candidate_path = f"{candidate_path}?{query_part}" + if _path_is_ambiguous(candidate_path): + flow.response = http.Response.make( + 403, + b"request path contains ambiguous substituted segments\n", + {"content-type": "text/plain"}, + ) + ctx.log.warn( + "credential proxy: rejected request after path substitution: " + f"{flow.request.method} {_request_host(flow)} path=[REDACTED]" + ) + return applied + + if path_changed or query_changed: + flow.request.path = path_part if query_part is None else f"{path_part}?{query_part}" + return applied + + +def _apply_header_substitutions( + flow: http.HTTPFlow, + substitutions: list[dict[str, Any]], +) -> list[str]: + applied: list[str] = [] + replacements = _substitution_replacements(substitutions, "header") + if not replacements: + return applied + for name, header_value in list(flow.request.headers.items()): + if name.lower() in HEADER_SUBSTITUTION_DENYLIST: + continue + updated, header_applied = _replace_literals_once(str(header_value), replacements) + if header_applied: + flow.request.headers[name] = updated + applied.extend("header" for _ in header_applied) + return applied + + +def _apply_body_substitutions( + flow: http.HTTPFlow, + substitutions: list[dict[str, Any]], +) -> list[str]: + body = _request_body_bytes(flow) + if body is None: + return [] + + content_encoding = flow.request.headers.get("content-encoding", "").strip().lower() + if content_encoding and content_encoding != "identity": + return [] + + content_type = flow.request.headers.get("content-type", "") + if content_type.split(";", 1)[0].strip().lower().startswith("multipart/"): + return [] + + try: + text = body.decode("utf-8") + except UnicodeDecodeError: + return [] + + applied: list[str] = [] + replacements = _substitution_replacements(substitutions, "body", content_type) + text, body_applied = _replace_literals_once(text, replacements) + applied.extend("body" for _ in body_applied) + + if applied: + _set_request_body_bytes(flow, text.encode("utf-8")) + return applied + + +def _apply_substitutions(flow: http.HTTPFlow, binding: dict[str, Any]) -> list[str]: + substitutions = binding.get("substitutions") or [] + if not substitutions: + return [] + + applied = [] + applied.extend(_apply_path_query_substitutions(flow, substitutions)) + if flow.response is not None and getattr(flow.response, "status_code", None) == 403: + return applied + applied.extend(_apply_header_substitutions(flow, substitutions)) + applied.extend(_apply_body_substitutions(flow, substitutions)) + return applied + + +def request(flow: http.HTTPFlow) -> None: + vault = _load_active_vault() + if vault is None: + return + + # Reject requests with ambiguous path segments before credential injection. + # Raw dot-segments or encoded variants on the wire are not produced by + # legitimate HTTP clients and can trick prefix-based path matching into + # injecting credentials for a scope the canonical path does not belong to. + raw_path = flow.request.path or "/" + if _path_is_ambiguous(raw_path): + flow.response = http.Response.make( + 403, + b"request path contains ambiguous segments\n", + {"content-type": "text/plain"}, + ) + ctx.log.warn( + "credential proxy: rejected request with ambiguous path: " + f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}" + ) + return + + binding = _select_binding(flow, vault) + if not binding: + return + + substituted_surfaces = _apply_substitutions(flow, binding) + if flow.response is not None and getattr(flow.response, "status_code", None) == 403: + return + + injected_names: list[str] = [] + for header in binding.get("headers") or []: + name = header.get("name") + value = header.get("value") + if not name or value is None: + continue + # mitmproxy Headers is case-insensitive; delete first to avoid duplicate + # effective header names before setting the credentialed value. + if name in flow.request.headers: + del flow.request.headers[name] + flow.request.headers[name] = value + injected_names.append(name) + + if injected_names or substituted_surfaces: + flow.metadata[FLOW_REDACTIONS_KEY] = list(vault.redactions) + ctx.log.info( + "credential proxy: applied binding=" + f"{binding.get('name')} revision={vault.revision} " + f"host={_request_host(flow)} method={flow.request.method} " + f"headers={','.join(injected_names)} " + f"substitutions={','.join(sorted(set(substituted_surfaces)))}" + ) + elif binding.get("substitutions"): + ctx.log.info( + "credential proxy: substitution miss binding=" + f"{binding.get('name')} revision={vault.revision} " + f"host={_request_host(flow)} method={flow.request.method}" + ) + + +def responseheaders(flow: http.HTTPFlow) -> None: + if flow.response is None: + return + _redact_response_headers(flow) + content_type = flow.response.headers.get("content-type", "").lower() + transfer_encoding = flow.response.headers.get("transfer-encoding", "").lower() + if "text/event-stream" in content_type or "chunked" in transfer_encoding: + flow.response.stream = True + + +def _redact_response_headers(flow: http.HTTPFlow) -> None: + redactions = flow.metadata.get(FLOW_REDACTIONS_KEY, []) + if not redactions or flow.response is None: + return + for name, value in list(flow.response.headers.items()): + redacted = _redact_text(value, redactions) + if redacted != value: + flow.response.headers[name] = redacted + + +def _redact_text(text: str, values: list[str]) -> str: + out = text + for value in sorted({value for value in values if value}, key=len, reverse=True): + out = out.replace(value, "[REDACTED]") + return out diff --git a/components/egress/nameserver.go b/components/egress/nameserver.go new file mode 100644 index 0000000..4133314 --- /dev/null +++ b/components/egress/nameserver.go @@ -0,0 +1,91 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "net/netip" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +// AllowIPsForNft builds the extra allow-IP list for dns+nft: 127.0.0.1 (local DNS listen after REDIRECT), +// then up to ResolvNameserverCap non-loopback / non-0.0.0.0 nameservers from resolvPath. +func AllowIPsForNft(resolvPath string) []netip.Addr { + raw, _ := dnsproxy.ResolvNameserverIPs(resolvPath) + maxNsCount := constants.ResolvNameserverCap + + var validated []netip.Addr + for _, ip := range raw { + if len(validated) >= maxNsCount { + break + } + if !isValidNameserverIP(ip) { + continue + } + validated = append(validated, ip) + } + + out := make([]netip.Addr, 0, 1+len(validated)) // 127.0.0.1 first: redirect target must be allowed + out = append(out, netip.MustParseAddr("127.0.0.1")) + out = append(out, validated...) + + if len(out) > 1 { + log.Infof("[dns] whitelisting proxy listen + %d nameserver(s) for nft: %v", len(validated), formatIPs(out)) + } else { + log.Infof("[dns] whitelisting proxy listen (127.0.0.1); no valid nameserver IPs from %s", resolvPath) + } + return out +} + +func isValidNameserverIP(ip netip.Addr) bool { + if ip.IsUnspecified() { + return false + } + if ip.IsLoopback() { + return false + } + return true +} + +func formatIPs(ips []netip.Addr) []string { + out := make([]string, len(ips)) + for i, ip := range ips { + out[i] = ip.String() + } + return out +} + +func allowIps() []netip.Addr { + upstreams, err := dnsproxy.DiscoverUpstreams() + if err != nil { + log.Fatalf("failed to resolve DNS upstreams: %v", err) + } + allowIPs := AllowIPsForNft("/etc/resolv.conf") + for _, addr := range dnsproxy.AllowIPsFromUpstreamAddrs(upstreams) { + if !containsAddr(allowIPs, addr) { + allowIPs = append(allowIPs, addr) + } + } + + // Exempt destinations: proxy dials them without SO_MARK; they must still be allowed by nft. + for _, addr := range dnsproxy.ParseNameserverExemptList() { + if !containsAddr(allowIPs, addr) { + allowIPs = append(allowIPs, addr) + } + } + return allowIPs +} diff --git a/components/egress/nameserver_test.go b/components/egress/nameserver_test.go new file mode 100644 index 0000000..92960be --- /dev/null +++ b/components/egress/nameserver_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAllowIPsForNft_EmptyResolv(t *testing.T) { + dir := t.TempDir() + resolv := filepath.Join(dir, "resolv.conf") + require.NoError(t, os.WriteFile(resolv, []byte("# empty\n"), 0644)) + ips := AllowIPsForNft(resolv) + require.Len(t, ips, 1, "expected 1 IP (127.0.0.1)") + require.Equal(t, netip.MustParseAddr("127.0.0.1"), ips[0]) +} + +func TestAllowIPsForNft_ValidNameservers(t *testing.T) { + dir := t.TempDir() + resolv := filepath.Join(dir, "resolv.conf") + // Standard resolv.conf with two nameservers + content := "nameserver 192.168.65.7\nnameserver 10.0.0.1\n" + require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) + ips := AllowIPsForNft(resolv) + require.Len(t, ips, 3, "expected 3 IPs (127.0.0.1 + 2 nameservers)") + require.Equal(t, netip.MustParseAddr("127.0.0.1"), ips[0], "expected first 127.0.0.1") + require.Equal(t, netip.MustParseAddr("192.168.65.7"), ips[1], "expected 192.168.65.7") + require.Equal(t, netip.MustParseAddr("10.0.0.1"), ips[2], "expected 10.0.0.1") +} + +func TestAllowIPsForNft_FiltersInvalid(t *testing.T) { + dir := t.TempDir() + resolv := filepath.Join(dir, "resolv.conf") + // 0.0.0.0 and 127.0.0.11 should be filtered; 192.168.1.1 kept + content := "nameserver 0.0.0.0\nnameserver 192.168.1.1\nnameserver 127.0.0.11\n" + require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) + ips := AllowIPsForNft(resolv) + require.Len(t, ips, 2, "expected 2 IPs (127.0.0.1 + 192.168.1.1)") + require.Equal(t, netip.MustParseAddr("127.0.0.1"), ips[0], "expected first 127.0.0.1") + require.Equal(t, netip.MustParseAddr("192.168.1.1"), ips[1], "expected 192.168.1.1") +} + +func TestAllowIPsForNft_Cap(t *testing.T) { + dir := t.TempDir() + resolv := filepath.Join(dir, "resolv.conf") + var lines []string + for i := 1; i <= 11; i++ { + lines = append(lines, fmt.Sprintf("nameserver 10.0.0.%d", i)) + } + content := strings.Join(lines, "\n") + "\n" + require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) + + ips := AllowIPsForNft(resolv) + // 127.0.0.1 + first 10 nameservers (fixed cap) + require.Len(t, ips, 11, "expected 11 IPs (127.0.0.1 + 10 from resolv)") + require.Equal(t, netip.MustParseAddr("10.0.0.1"), ips[1], "expected first nameserver to be 10.0.0.1") + require.Equal(t, netip.MustParseAddr("10.0.0.10"), ips[10], "expected tenth nameserver to be 10.0.0.10") +} + +func TestIsValidNameserverIP(t *testing.T) { + tests := []struct { + ip string + want bool + }{ + {"0.0.0.0", false}, + {"::", false}, + {"127.0.0.1", false}, + {"127.0.0.11", false}, + {"::1", false}, + {"192.168.65.7", true}, + {"10.0.0.1", true}, + {"8.8.8.8", true}, + } + for _, tt := range tests { + ip := netip.MustParseAddr(tt.ip) + got := isValidNameserverIP(ip) + if got != tt.want { + t.Errorf("isValidNameserverIP(%s) = %v, want %v", tt.ip, got, tt.want) + } + } +} diff --git a/components/egress/nft.go b/components/egress/nft.go new file mode 100644 index 0000000..c9406fb --- /dev/null +++ b/components/egress/nft.go @@ -0,0 +1,95 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "net/netip" + "os" + "strings" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/nftables" + "github.com/alibaba/opensandbox/egress/pkg/policy" +) + +// createNftManager is non-nil only when mode includes the nft token (e.g. dns+nft). +func createNftManager(mode string) nftApplier { + if !constants.ModeUsesNft(mode) { + return nil + } + return nftables.NewManagerWithOptions(parseNftOptions()) +} + +// setupNft: apply static policy to nft, then wire allowed DNS answers to AddResolvedIPs (dynamic allow sets). +// nameserverIPs and always-deny/allow follow the same merge rules as the policy API (MergeAlwaysOverlay + WithExtraAllowIPs). +func setupNft(ctx context.Context, nftMgr nftApplier, initialPolicy *policy.NetworkPolicy, proxy *dnsproxy.Proxy, nameserverIPs []netip.Addr, alwaysDeny, alwaysAllow []policy.EgressRule) { + if nftMgr == nil { + log.Warnf("nftables disabled (dns-only mode)") + return + } + + log.Infof("applying nftables static policy (dns+nft mode) with %d nameserver IP(s) merged into allow set", len(nameserverIPs)) + merged := policy.MergeAlwaysOverlay(initialPolicy, alwaysDeny, alwaysAllow) + policyWithNS := merged.WithExtraAllowIPs(nameserverIPs) + if err := nftMgr.ApplyStatic(ctx, policyWithNS); err != nil { + log.Fatalf("nftables static apply failed: %v", err) + } + log.Infof("nftables static policy applied (table inet opensandbox); DNS-resolved IPs will be added to dynamic allow sets") + proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { + addCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := nftMgr.AddResolvedIPs(addCtx, ips); err != nil { + log.Warnf("[dns] add resolved IPs to nft failed for domain %q: %v", domain, err) + } + }) +} + +func parseNftOptions() nftables.Options { + opts := nftables.Options{BlockDoT: true} + if constants.IsTruthy(os.Getenv(constants.EnvBlockDoH443)) { + opts.BlockDoH443 = true + } + if raw := os.Getenv(constants.EnvDoHBlocklist); strings.TrimSpace(raw) != "" { + parts := strings.Split(raw, ",") + for _, p := range parts { + target := strings.TrimSpace(p) + if target == "" { + continue + } + if addr, err := netip.ParseAddr(target); err == nil { + if addr.Is4() { + opts.DoHBlocklistV4 = append(opts.DoHBlocklistV4, target) + } else if addr.Is6() { + opts.DoHBlocklistV6 = append(opts.DoHBlocklistV6, target) + } + continue + } + if prefix, err := netip.ParsePrefix(target); err == nil { + if prefix.Addr().Is4() { + opts.DoHBlocklistV4 = append(opts.DoHBlocklistV4, target) + } else if prefix.Addr().Is6() { + opts.DoHBlocklistV6 = append(opts.DoHBlocklistV6, target) + } + continue + } + log.Warnf("ignoring invalid DoH blocklist entry: %s", target) + } + } + return opts +} diff --git a/components/egress/pkg/constants/configuration.go b/components/egress/pkg/constants/configuration.go new file mode 100644 index 0000000..2f93f88 --- /dev/null +++ b/components/egress/pkg/constants/configuration.go @@ -0,0 +1,94 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package constants + +import ( + "os" + "strconv" + "strings" +) + +const EnvCredentialVaultTrustedProxyCIDRs = "OPENSANDBOX_EGRESS_CREDENTIAL_VAULT_TRUSTED_PROXY_CIDRS" + +const ( + EnvBlockDoH443 = "OPENSANDBOX_EGRESS_BLOCK_DOH_443" + EnvDoHBlocklist = "OPENSANDBOX_EGRESS_DOH_BLOCKLIST" + EnvEgressMode = "OPENSANDBOX_EGRESS_MODE" + EnvEgressHTTPAddr = "OPENSANDBOX_EGRESS_HTTP_ADDR" + EnvEgressToken = "OPENSANDBOX_EGRESS_TOKEN" + EnvCredentialProxySocket = "OPENSANDBOX_CREDENTIAL_PROXY_SOCKET" + EnvEgressRules = "OPENSANDBOX_EGRESS_RULES" + EnvEgressPolicyFile = "OPENSANDBOX_EGRESS_POLICY_FILE" + EnvEgressLogLevel = "OPENSANDBOX_EGRESS_LOG_LEVEL" + EnvMaxEgressRules = "OPENSANDBOX_EGRESS_MAX_RULES" + EnvBlockedWebhook = "OPENSANDBOX_EGRESS_DENY_WEBHOOK" + EnvSandboxID = "OPENSANDBOX_EGRESS_SANDBOX_ID" + EnvEgressMetricsExtraAttrs = "OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS" + EnvNameserverExempt = "OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT" + EnvCredentialVaultRequireTLS = "OPENSANDBOX_EGRESS_CREDENTIAL_VAULT_REQUIRE_TLS" + + // MITM: mitmdump transparent; Linux + CAP_NET_ADMIN, runs as a dedicated user. + // Static mitm options (mode, connection_strategy, listen_host, stream_large_bodies, + // ignore_hosts, ssl_verify_upstream_trusted_confdir default) live in + // /var/lib/mitmproxy/.mitmproxy/config.yaml; only per-deployment overrides are env-driven. + EnvMitmproxyTransparent = "OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT" + EnvMitmproxyPort = "OPENSANDBOX_EGRESS_MITMPROXY_PORT" + EnvMitmproxyScript = "OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT" + EnvMitmproxyUpstreamTrustDir = "OPENSANDBOX_EGRESS_MITMPROXY_UPSTREAM_TRUST_DIR" + EnvMitmproxySslInsecure = "OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE" + + // Comma-separated upstream resolvers: literal IP only (optional :port) — no hostnames (see dnsproxy REDIRECT note). + EnvDNSUpstream = "OPENSANDBOX_EGRESS_DNS_UPSTREAM" + EnvDNSUpstreamTimeout = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT" + EnvDNSUpstreamProbe = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE" + EnvDNSUpstreamProbeIntervalSec = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE_INTERVAL_SEC" +) + +const ( + PolicyDnsOnly = "dns" + PolicyDnsNft = "dns+nft" +) + +const ( + DefaultEgressServerAddr = ":18080" + DefaultMitmproxyPort = 18081 + DefaultCredentialProxySocket = "/run/opensandbox/credential-proxy/active.sock" + ResolvNameserverCap = 10 + DefaultMaxEgressRules = 4096 + DefaultDNSUpstreamTimeoutSec = 5 + + OpenSandboxRootDir = "/opt/opensandbox" +) + +func EnvIntOrDefault(key string, defaultVal int) int { + s := strings.TrimSpace(os.Getenv(key)) + if s == "" { + return defaultVal + } + v, err := strconv.Atoi(s) + if err != nil { + return defaultVal + } + return v +} + +func IsTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} diff --git a/components/egress/pkg/constants/constants.go b/components/egress/pkg/constants/constants.go new file mode 100644 index 0000000..8971e32 --- /dev/null +++ b/components/egress/pkg/constants/constants.go @@ -0,0 +1,24 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package constants + +const ( + MarkValue = 0x1 + MarkHex = "0x1" +) + +const ( + EgressAuthTokenHeader = "OPENSANDBOX-EGRESS-AUTH" +) diff --git a/components/egress/pkg/constants/mode.go b/components/egress/pkg/constants/mode.go new file mode 100644 index 0000000..adf8712 --- /dev/null +++ b/components/egress/pkg/constants/mode.go @@ -0,0 +1,69 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package constants + +import ( + "fmt" + "strings" +) + +// ParseEgressMode: tokens "dns" (required) and "nft", joined with +, order-free. Empty env → dns-only. +func ParseEgressMode(raw string) (string, error) { + seen, err := parseModeTokens(raw) + if err != nil { + return "", err + } + return normalizeMode(seen), nil +} + +func parseModeTokens(raw string) (map[string]bool, error) { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return map[string]bool{"dns": true}, nil + } + parts := strings.Split(raw, "+") + seen := make(map[string]bool) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + switch p { + case "dns", "nft": + seen[p] = true + default: + return nil, fmt.Errorf("unknown mode token %q", p) + } + } + if !seen["dns"] { + return nil, fmt.Errorf("egress mode must include dns") + } + return seen, nil +} + +func normalizeMode(seen map[string]bool) string { + if !seen["nft"] { + return PolicyDnsOnly + } + return PolicyDnsNft +} + +func ModeUsesNft(mode string) bool { + seen, err := parseModeTokens(mode) + if err != nil { + return false + } + return seen["nft"] +} diff --git a/components/egress/pkg/constants/mode_test.go b/components/egress/pkg/constants/mode_test.go new file mode 100644 index 0000000..f601621 --- /dev/null +++ b/components/egress/pkg/constants/mode_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package constants + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseEgressMode(t *testing.T) { + s, err := ParseEgressMode("") + require.NoError(t, err) + require.Equal(t, PolicyDnsOnly, s) + + s, err = ParseEgressMode("dns") + require.NoError(t, err) + require.Equal(t, PolicyDnsOnly, s) + + s, err = ParseEgressMode("dns+nft") + require.NoError(t, err) + require.Equal(t, PolicyDnsNft, s) + + s, err = ParseEgressMode("nft+dns") + require.NoError(t, err) + require.Equal(t, PolicyDnsNft, s) + + _, err = ParseEgressMode("nft") + require.Error(t, err) + + _, err = ParseEgressMode("dns+unknown") + require.Error(t, err) + + _, err = ParseEgressMode("dns+mitm") + require.Error(t, err) +} + +func TestModeUsesNft(t *testing.T) { + require.False(t, ModeUsesNft("")) + require.False(t, ModeUsesNft(PolicyDnsOnly)) + require.True(t, ModeUsesNft(PolicyDnsNft)) + require.True(t, ModeUsesNft(" DNS+nft ")) +} diff --git a/components/egress/pkg/credentialvault/active_socket.go b/components/egress/pkg/credentialvault/active_socket.go new file mode 100644 index 0000000..70439ac --- /dev/null +++ b/components/egress/pkg/credentialvault/active_socket.go @@ -0,0 +1,114 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialvault + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" +) + +func StartActiveSocketServer( + activeHandler func(http.ResponseWriter), + socketPath string, + socketGID int, +) (*http.Server, func(context.Context) error, error) { + if activeHandler == nil { + return nil, nil, fmt.Errorf("active credential vault handler is required") + } + if socketPath == "" { + return nil, nil, fmt.Errorf("socket path is required") + } + if !filepath.IsAbs(socketPath) { + return nil, nil, fmt.Errorf("socket path must be absolute") + } + + dir := filepath.Dir(socketPath) + if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { + return nil, nil, fmt.Errorf("create socket parent directory: %w", err) + } + if err := os.MkdirAll(dir, 0o710); err != nil { + return nil, nil, fmt.Errorf("create socket directory: %w", err) + } + if socketGID >= 0 { + if err := os.Chown(dir, os.Geteuid(), socketGID); err != nil { + return nil, nil, fmt.Errorf("set socket directory ownership: %w", err) + } + } + if err := os.Chmod(dir, 0o710); err != nil { + return nil, nil, fmt.Errorf("set socket directory mode: %w", err) + } + + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, nil, fmt.Errorf("remove stale socket: %w", err) + } + listener, err := net.Listen("unix", socketPath) + if err != nil { + return nil, nil, fmt.Errorf("listen: %w", err) + } + if socketGID >= 0 { + if err := os.Chown(socketPath, os.Geteuid(), socketGID); err != nil { + _ = listener.Close() + _ = os.Remove(socketPath) + return nil, nil, fmt.Errorf("set socket ownership: %w", err) + } + } + if err := os.Chmod(socketPath, 0o660); err != nil { + _ = listener.Close() + _ = os.Remove(socketPath) + return nil, nil, fmt.Errorf("set socket mode: %w", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/credential-vault/_active", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + activeHandler(w) + }) + + srv := &http.Server{Handler: mux} + errCh := make(chan error, 1) + go func() { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + select { + case err := <-errCh: + _ = os.Remove(socketPath) + return nil, nil, err + default: + } + + cleanup := func(ctx context.Context) error { + shutdownErr := srv.Shutdown(ctx) + removeErr := os.Remove(socketPath) + if shutdownErr != nil && !errors.Is(shutdownErr, http.ErrServerClosed) { + return shutdownErr + } + if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return removeErr + } + return nil + } + return srv, cleanup, nil +} diff --git a/components/egress/pkg/credentialvault/source.go b/components/egress/pkg/credentialvault/source.go new file mode 100644 index 0000000..7b70898 --- /dev/null +++ b/components/egress/pkg/credentialvault/source.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialvault + +import ( + "context" + "encoding/json" + "fmt" +) + +// CredentialSource resolves credential material from a configured source. +// Implementations must be safe for concurrent use. +type CredentialSource interface { + // Type returns the source discriminator (e.g. "inline"). + Type() string + // Resolve returns the plaintext credential value. + // Context allows future providers to perform network calls with + // timeout/cancellation support. + Resolve(ctx context.Context) (string, error) +} + +// CredentialSourceFactory creates a CredentialSource from raw JSON source +// configuration. The raw bytes are the full "source" object from the API +// request (including the "type" field). +type CredentialSourceFactory func(raw json.RawMessage) (CredentialSource, error) + +// SourceRegistry maps source type discriminators to their factories. +type SourceRegistry struct { + factories map[string]CredentialSourceFactory +} + +// NewSourceRegistry creates a registry with the built-in inline provider +// pre-registered. +func NewSourceRegistry() *SourceRegistry { + r := &SourceRegistry{ + factories: make(map[string]CredentialSourceFactory), + } + r.Register("inline", inlineSourceFactory) + return r +} + +// Register adds a factory for the given source type. Panics if the type is +// already registered. +func (r *SourceRegistry) Register(sourceType string, factory CredentialSourceFactory) { + if _, exists := r.factories[sourceType]; exists { + panic(fmt.Sprintf("credential source type %q already registered", sourceType)) + } + r.factories[sourceType] = factory +} + +// Create extracts the "type" discriminator from raw JSON and delegates to the +// matching factory. Returns an error for unknown or missing types. +func (r *SourceRegistry) Create(raw json.RawMessage) (CredentialSource, error) { + sourceType, err := extractSourceType(raw) + if err != nil { + return nil, err + } + factory, ok := r.factories[sourceType] + if !ok { + return nil, fmt.Errorf("unsupported credential source type %q", sourceType) + } + return factory(raw) +} + +// SupportedTypes returns all registered source type names. +func (r *SourceRegistry) SupportedTypes() []string { + types := make([]string, 0, len(r.factories)) + for t := range r.factories { + types = append(types, t) + } + return types +} + +func extractSourceType(raw json.RawMessage) (string, error) { + if len(raw) == 0 { + return "inline", nil + } + var envelope struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return "", fmt.Errorf("parse credential source: %w", err) + } + if envelope.Type == "" { + return "inline", nil + } + return envelope.Type, nil +} diff --git a/components/egress/pkg/credentialvault/source_inline.go b/components/egress/pkg/credentialvault/source_inline.go new file mode 100644 index 0000000..3d764fa --- /dev/null +++ b/components/egress/pkg/credentialvault/source_inline.go @@ -0,0 +1,49 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialvault + +import ( + "bytes" + "context" + "encoding/json" + "fmt" +) + +type inlineSource struct { + value string +} + +func (s *inlineSource) Type() string { return "inline" } +func (s *inlineSource) Resolve(_ context.Context) (string, error) { return s.value, nil } + +// inlineSourceFactory creates an inlineSource from the raw JSON "source" +// object. Expected shape: {"type": "inline", "value": ""}. +// Unknown fields are rejected to match the OpenAPI additionalProperties: false +// contract. +func inlineSourceFactory(raw json.RawMessage) (CredentialSource, error) { + var src struct { + Type string `json:"type"` + Value string `json:"value"` + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&src); err != nil { + return nil, fmt.Errorf("parse inline credential source: %w", err) + } + if src.Value == "" { + return nil, fmt.Errorf("inline credential value cannot be empty") + } + return &inlineSource{value: src.Value}, nil +} diff --git a/components/egress/pkg/credentialvault/vault.go b/components/egress/pkg/credentialvault/vault.go new file mode 100644 index 0000000..c0c9711 --- /dev/null +++ b/components/egress/pkg/credentialvault/vault.go @@ -0,0 +1,1256 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialvault + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "net/url" + "os" + "regexp" + "sort" + "strings" + "sync" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/mitmproxy" + "github.com/alibaba/opensandbox/egress/pkg/policy" +) + +const ( + maxCredentialVaultBodyBytes = 1 << 20 + mitmproxyConfigPath = "/var/lib/mitmproxy/.mitmproxy/config.yaml" +) + +var ( + ErrNotFound = errors.New("credential vault not found") + ErrExists = errors.New("credential vault already exists") + + headerFieldNamePattern = regexp.MustCompile(`^[A-Za-z0-9!#$%&'*+\-.^_` + "`" + `|~]+$`) + hostnameLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`) + reservedHeaderNames = map[string]struct{}{ + "host": {}, + "content-length": {}, + "content-type": {}, + "transfer-encoding": {}, + "connection": {}, + "upgrade": {}, + "te": {}, + "trailer": {}, + "proxy-authorization": {}, + "proxy-authenticate": {}, + "forwarded": {}, + "x-forwarded-for": {}, + "x-forwarded-host": {}, + "x-forwarded-proto": {}, + } +) + +type Store struct { + mu sync.RWMutex + exists bool + revision int64 + credentials map[string]record + bindings map[string]Binding + mitmGate *mitmproxy.HealthGate + requireToken func() bool + sources *SourceRegistry +} + +type record struct { + Name string + SourceType string + Source CredentialSource + Revision int64 +} + +type CreateRequest struct { + Credentials []Credential `json:"credentials"` + Bindings []Binding `json:"bindings"` +} + +type MutationRequest struct { + ExpectedRevision *int64 `json:"expectedRevision,omitempty"` + Credentials *CredentialMutationSet `json:"credentials,omitempty"` + Bindings *BindingMutationSet `json:"bindings,omitempty"` +} + +type CredentialMutationSet struct { + Add []Credential `json:"add,omitempty"` + Replace []Credential `json:"replace,omitempty"` + Delete []string `json:"delete,omitempty"` +} + +type BindingMutationSet struct { + Add []Binding `json:"add,omitempty"` + Replace []Binding `json:"replace,omitempty"` + Delete []string `json:"delete,omitempty"` +} + +type Credential struct { + Name string `json:"name"` + Source json.RawMessage `json:"source"` +} + +type Binding struct { + Name string `json:"name"` + Match Match `json:"match"` + Auth Auth `json:"auth"` +} + +type Match struct { + Schemes []string `json:"schemes,omitempty"` + Ports []int `json:"ports,omitempty"` // Deprecated: ignored, port is derived from scheme. + Hosts []string `json:"hosts"` + Methods []string `json:"methods,omitempty"` + Paths []string `json:"paths,omitempty"` +} + +type Auth struct { + Type string `json:"type"` + Credential string `json:"credential,omitempty"` + Name string `json:"name,omitempty"` + Headers []CustomHeaderEntry `json:"headers,omitempty"` + Substitutions []Substitution `json:"substitutions,omitempty"` +} + +type CustomHeaderEntry struct { + Name string `json:"name"` + Credential string `json:"credential"` +} + +type Substitution struct { + Credential string `json:"credential"` + Placeholder string `json:"placeholder"` + In []string `json:"in"` +} + +type State struct { + Revision int64 `json:"revision"` + Credentials []Metadata `json:"credentials"` + Bindings []BindingMetadata `json:"bindings"` +} + +type ListResponse struct { + Revision int64 `json:"revision"` + Credentials []Metadata `json:"credentials"` +} + +type BindingListResponse struct { + Revision int64 `json:"revision"` + Bindings []BindingMetadata `json:"bindings"` +} + +type Metadata struct { + Name string `json:"name"` + SourceType string `json:"sourceType"` + Revision int64 `json:"revision"` +} + +type BindingMetadata struct { + Name string `json:"name"` + Revision int64 `json:"revision"` + Match Match `json:"match"` + Auth AuthMetadata `json:"auth"` +} + +type AuthMetadata struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +type ActiveSnapshot struct { + Revision int64 `json:"revision"` + Bindings []ActiveBinding `json:"bindings"` + Redactions []string `json:"redactions,omitempty"` +} + +type ActiveBinding struct { + Name string `json:"name"` + Match Match `json:"match"` + Headers []InjectionHeader `json:"headers"` + Substitutions []InjectionSubstitution `json:"substitutions,omitempty"` +} + +type InjectionHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type InjectionSubstitution struct { + Placeholder string `json:"placeholder"` + Value string `json:"value"` + In []string `json:"in"` +} + +func NewStore(mitmGate *mitmproxy.HealthGate, requireToken func() bool) *Store { + return NewStoreWithRegistry(mitmGate, requireToken, nil) +} + +// NewStoreWithRegistry creates a Store with a custom SourceRegistry. If +// registry is nil, the default registry (with inline pre-registered) is used. +func NewStoreWithRegistry(mitmGate *mitmproxy.HealthGate, requireToken func() bool, registry *SourceRegistry) *Store { + if registry == nil { + registry = NewSourceRegistry() + } + return &Store{ + credentials: make(map[string]record), + bindings: make(map[string]Binding), + mitmGate: mitmGate, + requireToken: requireToken, + sources: registry, + } +} + +func (v *Store) Create(req CreateRequest, pol *policy.NetworkPolicy) (State, error) { + v.mu.Lock() + defer v.mu.Unlock() + if v.exists { + return State{}, ErrExists + } + + credentials := make(map[string]record, len(req.Credentials)) + bindings := make(map[string]Binding, len(req.Bindings)) + for _, c := range req.Credentials { + rec, err := v.normalizeCredential(c, 1) + if err != nil { + return State{}, err + } + if _, ok := credentials[rec.Name]; ok { + return State{}, fmt.Errorf("duplicate credential name %q", rec.Name) + } + credentials[rec.Name] = rec + } + for _, b := range req.Bindings { + nb, err := normalizeBinding(b) + if err != nil { + return State{}, err + } + if _, ok := bindings[nb.Name]; ok { + return State{}, fmt.Errorf("duplicate binding name %q", nb.Name) + } + bindings[nb.Name] = nb + } + if err := v.validateCandidate(credentials, bindings, pol); err != nil { + return State{}, err + } + + v.exists = true + v.revision = 1 + v.credentials = credentials + v.bindings = bindings + return v.sanitizedLocked(), nil +} + +func (v *Store) Patch(req MutationRequest, pol *policy.NetworkPolicy) (State, error) { + v.mu.Lock() + defer v.mu.Unlock() + if !v.exists { + return State{}, ErrNotFound + } + if req.ExpectedRevision != nil && *req.ExpectedRevision != v.revision { + return State{}, fmt.Errorf("expectedRevision %d does not match current revision %d", *req.ExpectedRevision, v.revision) + } + + nextRevision := v.revision + 1 + credentials := cloneCredentialRecords(v.credentials) + bindings := cloneCredentialBindings(v.bindings) + + if err := v.applyCredentialMutations(credentials, req.Credentials, nextRevision); err != nil { + return State{}, err + } + if err := applyBindingMutations(bindings, req.Bindings); err != nil { + return State{}, err + } + if err := v.validateCandidate(credentials, bindings, pol); err != nil { + return State{}, err + } + + v.revision = nextRevision + v.credentials = credentials + v.bindings = bindings + return v.sanitizedLocked(), nil +} + +func (v *Store) Delete() error { + v.mu.Lock() + defer v.mu.Unlock() + if !v.exists { + return ErrNotFound + } + v.exists = false + v.revision = 0 + v.credentials = make(map[string]record) + v.bindings = make(map[string]Binding) + return nil +} + +func (v *Store) Sanitized() (State, error) { + v.mu.RLock() + defer v.mu.RUnlock() + if !v.exists { + return State{}, ErrNotFound + } + return v.sanitizedLocked(), nil +} + +func (v *Store) sanitizedLocked() State { + state := State{ + Revision: v.revision, + Credentials: make([]Metadata, 0, len(v.credentials)), + Bindings: make([]BindingMetadata, 0, len(v.bindings)), + } + for _, c := range v.credentials { + state.Credentials = append(state.Credentials, Metadata{ + Name: c.Name, + SourceType: c.SourceType, + Revision: c.Revision, + }) + } + for _, b := range v.bindings { + state.Bindings = append(state.Bindings, BindingMetadata{ + Name: b.Name, + Revision: v.revision, + Match: b.Match, + Auth: sanitizeAuth(b.Auth), + }) + } + sort.Slice(state.Credentials, func(i, j int) bool { return state.Credentials[i].Name < state.Credentials[j].Name }) + sort.Slice(state.Bindings, func(i, j int) bool { return state.Bindings[i].Name < state.Bindings[j].Name }) + return state +} + +func (v *Store) ActiveSnapshot() (ActiveSnapshot, error) { + return v.ActiveSnapshotWithContext(context.Background()) +} + +func (v *Store) ActiveSnapshotWithContext(ctx context.Context) (ActiveSnapshot, error) { + v.mu.RLock() + defer v.mu.RUnlock() + if !v.exists { + return ActiveSnapshot{}, ErrNotFound + } + snapshot := ActiveSnapshot{ + Revision: v.revision, + Bindings: make([]ActiveBinding, 0, len(v.bindings)), + } + redactions := make(map[string]struct{}) + names := make([]string, 0, len(v.bindings)) + for name := range v.bindings { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + b := v.bindings[name] + headers, values, err := renderInjectionHeaders(ctx, b.Auth, v.credentials) + if err != nil { + return ActiveSnapshot{}, err + } + substitutions, substitutionValues, err := renderSubstitutions(ctx, b.Auth, v.credentials) + if err != nil { + return ActiveSnapshot{}, err + } + snapshot.Bindings = append(snapshot.Bindings, ActiveBinding{ + Name: b.Name, + Match: b.Match, + Headers: headers, + Substitutions: substitutions, + }) + values = append(values, substitutionValues...) + for _, value := range values { + if value != "" { + redactions[value] = struct{}{} + } + } + } + for value := range redactions { + snapshot.Redactions = append(snapshot.Redactions, value) + } + sort.Slice(snapshot.Redactions, func(i, j int) bool { + if len(snapshot.Redactions[i]) != len(snapshot.Redactions[j]) { + return len(snapshot.Redactions[i]) > len(snapshot.Redactions[j]) + } + return snapshot.Redactions[i] < snapshot.Redactions[j] + }) + return snapshot, nil +} + +func (v *Store) ValidateActiveAgainstPolicy(pol *policy.NetworkPolicy) error { + v.mu.RLock() + defer v.mu.RUnlock() + if !v.exists || len(v.bindings) == 0 { + return nil + } + return v.validateCandidate(v.credentials, v.bindings, pol) +} + +func (v *Store) Ready(ctx context.Context) error { + if v.requireToken != nil && !v.requireToken() { + return fmt.Errorf("credential vault requires egress API auth token") + } + if !constants.IsTruthy(os.Getenv(constants.EnvMitmproxyTransparent)) { + return fmt.Errorf("credential vault requires transparent mitmproxy") + } + if constants.IsTruthy(os.Getenv(constants.EnvMitmproxySslInsecure)) { + return fmt.Errorf("credential vault rejects insecure upstream TLS mode") + } + if !constants.ModeUsesNft(os.Getenv(constants.EnvEgressMode)) { + return fmt.Errorf("credential vault requires dns+nft egress enforcement") + } + if v.mitmGate != nil && !v.mitmGate.WaitReady(ctx) { + return fmt.Errorf("credential proxy is not ready") + } + return nil +} + +func (v *Store) validateCandidate(credentials map[string]record, bindings map[string]Binding, pol *policy.NetworkPolicy) error { + if len(bindings) > 0 && pol == nil { + return fmt.Errorf("credential vault bindings require an egress policy") + } + if len(bindings) > 0 && pol.DefaultAction != policy.ActionDeny { + log.Warnf("credential vault: default-allow egress policy is deprecated and may allow credential destination bypass; use defaultAction=deny") + } + for _, b := range bindings { + if err := validateBindingCredentialRefs(b, credentials); err != nil { + return err + } + if err := v.validateBindingPolicy(b, pol); err != nil { + return err + } + } + if err := validateBindingAmbiguity(bindings); err != nil { + return err + } + return nil +} + +func (v *Store) validateBindingPolicy(b Binding, pol *policy.NetworkPolicy) error { + for _, host := range b.Match.Hosts { + if !explicitAllowCoversHost(pol, host) { + return fmt.Errorf("binding %q host %q is not allowed by egress policy", b.Name, host) + } + if bindingHostMatchesIgnoreHosts(host) { + return fmt.Errorf("binding %q host %q matches mitmproxy ignore_hosts", b.Name, host) + } + } + return nil +} + +func (v *Store) normalizeCredential(c Credential, revision int64) (record, error) { + name := strings.TrimSpace(c.Name) + if name == "" { + return record{}, fmt.Errorf("credential name cannot be blank") + } + source, err := v.sources.Create(c.Source) + if err != nil { + return record{}, fmt.Errorf("credential %q: %w", name, err) + } + return record{Name: name, SourceType: source.Type(), Source: source, Revision: revision}, nil +} + +func normalizeBinding(b Binding) (Binding, error) { + b.Name = strings.TrimSpace(b.Name) + if b.Name == "" { + return Binding{}, fmt.Errorf("binding name cannot be blank") + } + if err := normalizeMatch(&b.Match); err != nil { + return Binding{}, fmt.Errorf("binding %q: %w", b.Name, err) + } + if err := normalizeAuth(&b.Auth); err != nil { + return Binding{}, fmt.Errorf("binding %q: %w", b.Name, err) + } + return b, nil +} + +func normalizeMatch(m *Match) error { + if len(m.Schemes) == 0 { + m.Schemes = []string{"https"} + } + if len(m.Methods) == 0 { + m.Methods = []string{"GET", "POST", "PUT", "PATCH", "DELETE"} + } + if len(m.Paths) == 0 { + m.Paths = []string{"/*"} + } + if len(m.Hosts) == 0 { + return fmt.Errorf("match.hosts cannot be empty") + } + + for i, scheme := range m.Schemes { + scheme = strings.ToLower(strings.TrimSpace(scheme)) + if scheme != "https" && scheme != "http" { + return fmt.Errorf("unsupported scheme %q", scheme) + } + m.Schemes[i] = scheme + } + if len(m.Ports) > 0 { + for _, port := range m.Ports { + if port != 80 && port != 443 { + return fmt.Errorf("unsupported port %d: only ports 80 and 443 are supported (derived from scheme)", port) + } + } + m.Ports = nil + } + for i, host := range m.Hosts { + normalized, err := normalizeCredentialHost(host) + if err != nil { + return err + } + m.Hosts[i] = normalized + } + for i, method := range m.Methods { + method = strings.ToUpper(strings.TrimSpace(method)) + if method == "" { + return fmt.Errorf("method cannot be blank") + } + m.Methods[i] = method + } + for i, path := range m.Paths { + path = strings.TrimSpace(path) + if path == "" || !strings.HasPrefix(path, "/") { + return fmt.Errorf("path pattern must start with /") + } + m.Paths[i] = path + } + dedupeStringsInPlace(&m.Schemes) + dedupeStringsInPlace(&m.Hosts) + dedupeStringsInPlace(&m.Methods) + dedupeStringsInPlace(&m.Paths) + return nil +} + +func normalizeAuth(a *Auth) error { + a.Type = strings.TrimSpace(a.Type) + switch a.Type { + case "bearer", "basic": + a.Credential = strings.TrimSpace(a.Credential) + if a.Credential == "" { + return fmt.Errorf("%s auth requires credential", a.Type) + } + case "apiKey": + a.Name = canonicalHeaderName(strings.TrimSpace(a.Name)) + if err := validateCredentialHeaderName(a.Name); err != nil { + return err + } + a.Credential = strings.TrimSpace(a.Credential) + if a.Credential == "" { + return fmt.Errorf("%s auth requires credential", a.Type) + } + case "customHeaders": + if len(a.Headers) == 0 { + return fmt.Errorf("customHeaders auth requires headers") + } + seen := make(map[string]struct{}, len(a.Headers)) + for i := range a.Headers { + h := &a.Headers[i] + h.Name = canonicalHeaderName(strings.TrimSpace(h.Name)) + if err := validateCredentialHeaderName(h.Name); err != nil { + return err + } + key := strings.ToLower(h.Name) + if _, ok := seen[key]; ok { + return fmt.Errorf("duplicate custom header name %q", h.Name) + } + seen[key] = struct{}{} + h.Credential = strings.TrimSpace(h.Credential) + if h.Credential == "" { + return fmt.Errorf("customHeaders entry %q requires credential", h.Name) + } + } + case "passthrough": + if strings.TrimSpace(a.Credential) != "" { + return fmt.Errorf("passthrough auth does not accept credential") + } + if strings.TrimSpace(a.Name) != "" { + return fmt.Errorf("passthrough auth does not accept name") + } + if len(a.Headers) != 0 { + return fmt.Errorf("passthrough auth does not accept headers") + } + default: + return fmt.Errorf("unsupported auth type %q", a.Type) + } + return normalizeSubstitutions(a.Substitutions) +} + +func validateCredentialHeaderName(name string) error { + if name == "" || !headerFieldNamePattern.MatchString(name) { + return fmt.Errorf("invalid credential header name %q", name) + } + if _, denied := reservedHeaderNames[strings.ToLower(name)]; denied { + return fmt.Errorf("reserved credential header name %q", name) + } + return nil +} + +func validateBindingCredentialRefs(b Binding, credentials map[string]record) error { + for _, name := range credentialRefsForAuth(b.Auth) { + if _, ok := credentials[name]; !ok { + return fmt.Errorf("binding %q references unknown credential %q", b.Name, name) + } + } + return nil +} + +func credentialRefsForAuth(auth Auth) []string { + var out []string + if auth.Type == "customHeaders" { + for _, h := range auth.Headers { + out = append(out, h.Credential) + } + } else if auth.Type != "passthrough" && auth.Credential != "" { + out = append(out, auth.Credential) + } + for _, substitution := range auth.Substitutions { + out = append(out, substitution.Credential) + } + return out +} + +func resolveCredentialValue(ctx context.Context, name string, credentials map[string]record) (string, error) { + c, ok := credentials[name] + if !ok { + return "", fmt.Errorf("unknown credential %q", name) + } + return c.Source.Resolve(ctx) +} + +func renderInjectionHeaders(ctx context.Context, auth Auth, credentials map[string]record) ([]InjectionHeader, []string, error) { + var headers []InjectionHeader + var redactions []string + switch auth.Type { + case "bearer": + value, err := resolveCredentialValue(ctx, auth.Credential, credentials) + if err != nil { + return nil, nil, err + } + rendered := "Bearer " + value + headers = append(headers, InjectionHeader{Name: "Authorization", Value: rendered}) + redactions = append(redactions, value, rendered) + case "basic": + value, err := resolveCredentialValue(ctx, auth.Credential, credentials) + if err != nil { + return nil, nil, err + } + rendered := "Basic " + value + headers = append(headers, InjectionHeader{Name: "Authorization", Value: rendered}) + redactions = append(redactions, value, rendered) + case "apiKey": + value, err := resolveCredentialValue(ctx, auth.Credential, credentials) + if err != nil { + return nil, nil, err + } + headers = append(headers, InjectionHeader{Name: auth.Name, Value: value}) + redactions = append(redactions, value) + case "customHeaders": + for _, h := range auth.Headers { + value, err := resolveCredentialValue(ctx, h.Credential, credentials) + if err != nil { + return nil, nil, err + } + headers = append(headers, InjectionHeader{Name: h.Name, Value: value}) + redactions = append(redactions, value) + } + case "passthrough": + default: + return nil, nil, fmt.Errorf("unsupported auth type %q", auth.Type) + } + return headers, redactions, nil +} + +func renderSubstitutions(ctx context.Context, auth Auth, credentials map[string]record) ([]InjectionSubstitution, []string, error) { + substitutions := make([]InjectionSubstitution, 0, len(auth.Substitutions)) + redactions := make([]string, 0, len(auth.Substitutions)*6) + for _, substitution := range auth.Substitutions { + value, err := resolveCredentialValue(ctx, substitution.Credential, credentials) + if err != nil { + return nil, nil, err + } + substitutions = append(substitutions, InjectionSubstitution{ + Placeholder: substitution.Placeholder, + Value: value, + In: append([]string(nil), substitution.In...), + }) + redactions = append(redactions, substitution.Placeholder) + redactions = append(redactions, substitutionRedactionVariants(value)...) + } + return substitutions, redactions, nil +} + +func substitutionRedactionVariants(value string) []string { + urlEncoded := strings.ReplaceAll(url.QueryEscape(value), "+", "%20") + formEncoded := url.QueryEscape(value) + jsonEncoded := value + if data, err := json.Marshal(value); err == nil && len(data) >= 2 { + jsonEncoded = string(data[1 : len(data)-1]) + } + return []string{ + value, + urlEncoded, + lowercasePercentEscapes(urlEncoded), + formEncoded, + lowercasePercentEscapes(formEncoded), + jsonEncoded, + jsonASCIIEncodedStringContent(value), + } +} + +func lowercasePercentEscapes(value string) string { + var b strings.Builder + changed := false + for i := 0; i < len(value); i++ { + if value[i] == '%' && i+2 < len(value) && isHexDigit(value[i+1]) && isHexDigit(value[i+2]) { + b.WriteByte('%') + b.WriteByte(lowerHexByte(value[i+1])) + b.WriteByte(lowerHexByte(value[i+2])) + i += 2 + changed = true + continue + } + b.WriteByte(value[i]) + } + if !changed { + return value + } + return b.String() +} + +func isHexDigit(b byte) bool { + return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F') +} + +func lowerHexByte(b byte) byte { + if 'A' <= b && b <= 'F' { + return b + ('a' - 'A') + } + return b +} + +func jsonASCIIEncodedStringContent(value string) string { + var b strings.Builder + for _, r := range value { + switch r { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\b': + b.WriteString(`\b`) + case '\f': + b.WriteString(`\f`) + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + switch { + case r < 0x20: + fmt.Fprintf(&b, `\u%04x`, r) + case r < 0x80: + b.WriteRune(r) + case r <= 0xffff: + fmt.Fprintf(&b, `\u%04x`, r) + default: + v := r - 0x10000 + high := 0xd800 + (v >> 10) + low := 0xdc00 + (v & 0x3ff) + fmt.Fprintf(&b, `\u%04x\u%04x`, high, low) + } + } + } + return b.String() +} + +func sanitizeAuth(auth Auth) AuthMetadata { + meta := AuthMetadata{Type: auth.Type} + switch auth.Type { + case "apiKey": + meta.Name = auth.Name + } + return meta +} + +func normalizeSubstitutions(substitutions []Substitution) error { + allowed := map[string]struct{}{ + "path": {}, + "query": {}, + "header": {}, + "body": {}, + } + seenPairs := make(map[[2]string]int) + for i := range substitutions { + substitution := &substitutions[i] + substitution.Credential = strings.TrimSpace(substitution.Credential) + if substitution.Credential == "" { + return fmt.Errorf("substitution %d requires credential", i) + } + if strings.TrimSpace(substitution.Placeholder) == "" { + return fmt.Errorf("substitution %d requires placeholder", i) + } + if len(substitution.In) == 0 { + return fmt.Errorf("substitution %d requires at least one target surface", i) + } + seen := make(map[string]struct{}, len(substitution.In)) + normalized := make([]string, 0, len(substitution.In)) + for _, surface := range substitution.In { + surface = strings.ToLower(strings.TrimSpace(surface)) + if _, ok := allowed[surface]; !ok { + return fmt.Errorf("substitution %d has unsupported target surface %q", i, surface) + } + if _, duplicate := seen[surface]; duplicate { + continue + } + seen[surface] = struct{}{} + pair := [2]string{substitution.Placeholder, surface} + if previous, duplicate := seenPairs[pair]; duplicate { + return fmt.Errorf("substitution %d duplicates placeholder %q on %s surface from substitution %d", i, substitution.Placeholder, surface, previous) + } + seenPairs[pair] = i + normalized = append(normalized, surface) + } + substitution.In = normalized + } + return nil +} + +func (v *Store) applyCredentialMutations(credentials map[string]record, mutations *CredentialMutationSet, revision int64) error { + if mutations == nil { + return nil + } + mentioned := make(map[string]struct{}) + for _, name := range mutations.Delete { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("credential delete name cannot be blank") + } + if _, duplicate := mentioned[name]; duplicate { + return fmt.Errorf("credential %q mentioned by multiple operations", name) + } + mentioned[name] = struct{}{} + if _, ok := credentials[name]; !ok { + return fmt.Errorf("credential %q does not exist", name) + } + delete(credentials, name) + } + for _, raw := range mutations.Replace { + rec, err := v.normalizeCredential(raw, revision) + if err != nil { + return err + } + if _, duplicate := mentioned[rec.Name]; duplicate { + return fmt.Errorf("credential %q mentioned by multiple operations", rec.Name) + } + mentioned[rec.Name] = struct{}{} + if _, ok := credentials[rec.Name]; !ok { + return fmt.Errorf("credential %q does not exist", rec.Name) + } + credentials[rec.Name] = rec + } + addSeen := make(map[string]struct{}) + for _, raw := range mutations.Add { + rec, err := v.normalizeCredential(raw, revision) + if err != nil { + return err + } + if _, duplicate := mentioned[rec.Name]; duplicate { + return fmt.Errorf("credential %q mentioned by multiple operations", rec.Name) + } + if _, duplicate := addSeen[rec.Name]; duplicate { + return fmt.Errorf("duplicate credential add name %q", rec.Name) + } + addSeen[rec.Name] = struct{}{} + if _, ok := credentials[rec.Name]; ok { + return fmt.Errorf("credential %q already exists", rec.Name) + } + credentials[rec.Name] = rec + } + return nil +} + +func applyBindingMutations(bindings map[string]Binding, mutations *BindingMutationSet) error { + if mutations == nil { + return nil + } + mentioned := make(map[string]struct{}) + for _, name := range mutations.Delete { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("binding delete name cannot be blank") + } + if _, duplicate := mentioned[name]; duplicate { + return fmt.Errorf("binding %q mentioned by multiple operations", name) + } + mentioned[name] = struct{}{} + if _, ok := bindings[name]; !ok { + return fmt.Errorf("binding %q does not exist", name) + } + delete(bindings, name) + } + for _, raw := range mutations.Replace { + b, err := normalizeBinding(raw) + if err != nil { + return err + } + if _, duplicate := mentioned[b.Name]; duplicate { + return fmt.Errorf("binding %q mentioned by multiple operations", b.Name) + } + mentioned[b.Name] = struct{}{} + if _, ok := bindings[b.Name]; !ok { + return fmt.Errorf("binding %q does not exist", b.Name) + } + bindings[b.Name] = b + } + addSeen := make(map[string]struct{}) + for _, raw := range mutations.Add { + b, err := normalizeBinding(raw) + if err != nil { + return err + } + if _, duplicate := mentioned[b.Name]; duplicate { + return fmt.Errorf("binding %q mentioned by multiple operations", b.Name) + } + if _, duplicate := addSeen[b.Name]; duplicate { + return fmt.Errorf("duplicate binding add name %q", b.Name) + } + addSeen[b.Name] = struct{}{} + if _, ok := bindings[b.Name]; ok { + return fmt.Errorf("binding %q already exists", b.Name) + } + bindings[b.Name] = b + } + return nil +} + +func cloneCredentialRecords(in map[string]record) map[string]record { + out := make(map[string]record, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneCredentialBindings(in map[string]Binding) map[string]Binding { + out := make(map[string]Binding, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func normalizeCredentialHost(host string) (string, error) { + host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) + if host == "" { + return "", fmt.Errorf("host cannot be blank") + } + if strings.Contains(host, "://") || strings.Contains(host, "/") { + return "", fmt.Errorf("host %q must not include scheme or path", host) + } + if strings.HasPrefix(host, "*.") { + suffix := strings.TrimPrefix(host, "*.") + if suffix == "" || strings.Contains(suffix, "*") { + return "", fmt.Errorf("invalid wildcard host %q", host) + } + if _, err := netip.ParseAddr(suffix); err == nil { + return "", fmt.Errorf("wildcard host %q cannot target an IP address", host) + } + if !isValidCredentialFQDN(suffix) { + return "", fmt.Errorf("invalid wildcard host %q", host) + } + return "*." + suffix, nil + } + if strings.Contains(host, "*") { + return "", fmt.Errorf("invalid wildcard host %q", host) + } + if _, err := netip.ParseAddr(host); err == nil { + return "", fmt.Errorf("credential binding host %q must be an FQDN, not an IP address", host) + } + if !isValidCredentialFQDN(host) { + return "", fmt.Errorf("credential binding host %q must be an FQDN", host) + } + return host, nil +} + +func isValidCredentialFQDN(host string) bool { + if len(host) > 253 || !strings.Contains(host, ".") { + return false + } + for _, label := range strings.Split(host, ".") { + if !hostnameLabelPattern.MatchString(label) { + return false + } + } + return true +} + +func explicitAllowCoversHost(pol *policy.NetworkPolicy, host string) bool { + if pol == nil { + return false + } + host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) + if host == "" { + return false + } + if strings.HasPrefix(host, "*.") { + return pol.Evaluate("probe."+strings.TrimPrefix(host, "*.")) == policy.ActionAllow + } + return pol.Evaluate(host) == policy.ActionAllow +} + +func bindingHostMatchesIgnoreHosts(host string) bool { + patterns := parseMitmproxyIgnoreHosts(readMitmproxyConfig(mitmproxyConfigPath)) + if len(patterns) == 0 { + return false + } + candidates := []string{host} + if strings.HasPrefix(host, "*.") { + candidates = append(candidates, "probe."+strings.TrimPrefix(host, "*.")) + } + for _, part := range patterns { + part = strings.TrimSpace(part) + if part == "" { + continue + } + re, err := regexp.Compile(part) + if err != nil { + continue + } + for _, candidate := range candidates { + if re.MatchString(candidate) { + return true + } + } + } + return false +} + +func readMitmproxyConfig(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(data) +} + +func parseMitmproxyIgnoreHosts(config string) []string { + lines := strings.Split(config, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + key, value, ok := strings.Cut(trimmed, ":") + if !ok || strings.TrimSpace(key) != "ignore_hosts" { + continue + } + value = strings.TrimSpace(value) + if value != "" { + return parseMitmproxyInlineList(value) + } + var out []string + for _, itemLine := range lines[i+1:] { + itemTrimmed := strings.TrimSpace(itemLine) + if itemTrimmed == "" || strings.HasPrefix(itemTrimmed, "#") { + continue + } + if !strings.HasPrefix(itemLine, " ") && !strings.HasPrefix(itemLine, "\t") { + break + } + if !strings.HasPrefix(itemTrimmed, "-") { + continue + } + item := strings.TrimSpace(strings.TrimPrefix(itemTrimmed, "-")) + if item != "" { + out = append(out, trimYAMLScalar(item)) + } + } + return out + } + return nil +} + +func parseMitmproxyInlineList(value string) []string { + value = strings.TrimSpace(value) + if value == "" || value == "[]" { + return nil + } + if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") { + return []string{trimYAMLScalar(value)} + } + value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]")) + if value == "" { + return nil + } + var out []string + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, trimYAMLScalar(part)) + } + } + return out +} + +func trimYAMLScalar(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"') { + return value[1 : len(value)-1] + } + } + return value +} + +func validateBindingAmbiguity(bindings map[string]Binding) error { + list := make([]Binding, 0, len(bindings)) + for _, b := range bindings { + list = append(list, b) + } + for i := 0; i < len(list); i++ { + for j := i + 1; j < len(list); j++ { + if bindingsAmbiguous(list[i], list[j]) { + return fmt.Errorf("bindings %q and %q can match the same request", list[i].Name, list[j].Name) + } + } + } + return nil +} + +func bindingsAmbiguous(a, b Binding) bool { + if !stringSlicesOverlap(a.Match.Schemes, b.Match.Schemes) || + !stringSlicesOverlap(a.Match.Methods, b.Match.Methods) || + !pathPatternsOverlap(a.Match.Paths, b.Match.Paths) { + return false + } + return hostSetsAmbiguousAtSamePrecedence(a.Match.Hosts, b.Match.Hosts) +} + +func hostSetsAmbiguousAtSamePrecedence(aHosts, bHosts []string) bool { + for _, a := range aHosts { + for _, b := range bHosts { + aWild := strings.HasPrefix(a, "*.") + bWild := strings.HasPrefix(b, "*.") + if aWild != bWild { + continue + } + if !aWild && a == b { + return true + } + if aWild && wildcardHostsOverlap(a, b) { + return true + } + } + } + return false +} + +func wildcardHostsOverlap(a, b string) bool { + aSuffix := strings.TrimPrefix(a, "*.") + bSuffix := strings.TrimPrefix(b, "*.") + return aSuffix == bSuffix || strings.HasSuffix(aSuffix, "."+bSuffix) || strings.HasSuffix(bSuffix, "."+aSuffix) +} + +func pathPatternsOverlap(a, b []string) bool { + for _, x := range a { + for _, y := range b { + if pathPatternOverlaps(x, y) { + return true + } + } + } + return false +} + +func pathPatternOverlaps(a, b string) bool { + if a == b { + return true + } + if strings.HasSuffix(a, "*") { + if strings.HasPrefix(b, strings.TrimSuffix(a, "*")) { + return true + } + } + if strings.HasSuffix(b, "*") { + if strings.HasPrefix(a, strings.TrimSuffix(b, "*")) { + return true + } + } + if strings.HasSuffix(a, "*") && strings.HasSuffix(b, "*") { + pa := strings.TrimSuffix(a, "*") + pb := strings.TrimSuffix(b, "*") + return strings.HasPrefix(pa, pb) || strings.HasPrefix(pb, pa) + } + return false +} + +func stringSlicesOverlap(a, b []string) bool { + set := make(map[string]struct{}, len(a)) + for _, x := range a { + set[x] = struct{}{} + } + for _, y := range b { + if _, ok := set[y]; ok { + return true + } + } + return false +} + +func canonicalHeaderName(name string) string { + return http.CanonicalHeaderKey(name) +} + +func dedupeStringsInPlace(values *[]string) { + seen := make(map[string]struct{}, len(*values)) + out := (*values)[:0] + for _, value := range *values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + *values = out +} + +func ReadJSON(r *http.Request, dst any) error { + defer r.Body.Close() + dec := json.NewDecoder(io.LimitReader(r.Body, maxCredentialVaultBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return err + } + return nil +} + +func WriteError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrNotFound): + http.Error(w, err.Error(), http.StatusNotFound) + case errors.Is(err, ErrExists): + http.Error(w, err.Error(), http.StatusConflict) + case strings.Contains(err.Error(), "expectedRevision"): + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, err.Error(), http.StatusBadRequest) + } +} diff --git a/components/egress/pkg/credentialvault/vault_test.go b/components/egress/pkg/credentialvault/vault_test.go new file mode 100644 index 0000000..6418ffe --- /dev/null +++ b/components/egress/pkg/credentialvault/vault_test.go @@ -0,0 +1,380 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package credentialvault + +import ( + "encoding/json" + "testing" + + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/stretchr/testify/require" +) + +func mustMarshal(v any) json.RawMessage { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} + +func testCredentialPolicy(t *testing.T, raw string) *policy.NetworkPolicy { + t.Helper() + pol, err := policy.ParsePolicy(raw) + require.NoError(t, err) + return pol +} + +func testCredentialVaultRequest() CreateRequest { + return CreateRequest{ + Credentials: []Credential{ + { + Name: "gitlab-token", + Source: mustMarshal(map[string]string{"type": "inline", "value": "secret-token"}), + }, + }, + Bindings: []Binding{ + { + Name: "gitlab-api", + Match: Match{ + Hosts: []string{"code.example.com"}, + Methods: []string{"GET"}, + Paths: []string{"/api/v8/*"}, + }, + Auth: Auth{ + Type: "apiKey", + Name: "PRIVATE-TOKEN", + Credential: "gitlab-token", + }, + }, + }, + } +} + +func TestCredentialVaultCreateSanitizesAndRendersActiveSnapshot(t *testing.T) { + store := NewStore(nil, func() bool { return true }) + pol := testCredentialPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + + state, err := store.Create(testCredentialVaultRequest(), pol) + require.NoError(t, err) + require.Equal(t, int64(1), state.Revision) + require.Equal(t, []Metadata{{Name: "gitlab-token", SourceType: "inline", Revision: 1}}, state.Credentials) + require.Equal(t, "apiKey", state.Bindings[0].Auth.Type) + require.Equal(t, "Private-Token", state.Bindings[0].Auth.Name) + + payload, err := store.ActiveSnapshot() + require.NoError(t, err) + require.Equal(t, int64(1), payload.Revision) + require.Equal(t, []InjectionHeader{{Name: "Private-Token", Value: "secret-token"}}, payload.Bindings[0].Headers) + require.Contains(t, payload.Redactions, "secret-token") +} + +func TestCredentialVaultRendersScopedSubstitutions(t *testing.T) { + store := NewStore(nil, func() bool { return true }) + pol := testCredentialPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + + state, err := store.Create(CreateRequest{ + Credentials: []Credential{ + { + Name: "client-secret", + Source: mustMarshal(map[string]string{"type": "inline", "value": `real "clé"&value😀`}), + }, + }, + Bindings: []Binding{ + { + Name: "token-request", + Match: Match{ + Hosts: []string{"code.example.com"}, + Methods: []string{"POST"}, + Paths: []string{"/oauth/token"}, + }, + Auth: Auth{ + Type: "passthrough", + Substitutions: []Substitution{ + { + Credential: "client-secret", + Placeholder: "__client_secret__", + In: []string{"body", "query", "path", "body"}, + }, + }, + }, + }, + }, + }, pol) + require.NoError(t, err) + require.Equal(t, "passthrough", state.Bindings[0].Auth.Type) + require.NotContains(t, string(mustMarshal(state)), "__client_secret__") + require.NotContains(t, string(mustMarshal(state)), `real "secret"+value`) + + payload, err := store.ActiveSnapshot() + require.NoError(t, err) + require.Equal(t, int64(1), payload.Revision) + require.Empty(t, payload.Bindings[0].Headers) + require.Equal(t, []InjectionSubstitution{ + { + Placeholder: "__client_secret__", + Value: `real "clé"&value😀`, + In: []string{"body", "query", "path"}, + }, + }, payload.Bindings[0].Substitutions) + require.Contains(t, payload.Redactions, "__client_secret__") + require.Contains(t, payload.Redactions, `real "clé"&value😀`) + require.Contains(t, payload.Redactions, "real%20%22cl%C3%A9%22%26value%F0%9F%98%80") + require.Contains(t, payload.Redactions, "real%20%22cl%c3%a9%22%26value%f0%9f%98%80") + require.Contains(t, payload.Redactions, "real+%22cl%C3%A9%22%26value%F0%9F%98%80") + require.Contains(t, payload.Redactions, "real+%22cl%c3%a9%22%26value%f0%9f%98%80") + require.Contains(t, payload.Redactions, `real \"clé\"\u0026value😀`) + require.Contains(t, payload.Redactions, `real \"cl\u00e9\"&value\ud83d\ude00`) +} + +func TestCredentialVaultAllowsDefaultAllowPolicyForCompatibility(t *testing.T) { + store := NewStore(nil, func() bool { return true }) + pol := testCredentialPolicy(t, `{"defaultAction":"allow","egress":[]}`) + + state, err := store.Create(testCredentialVaultRequest(), pol) + require.NoError(t, err) + require.Len(t, state.Bindings, 1) +} + +func TestCredentialVaultDefaultAllowRespectsExplicitDenyRule(t *testing.T) { + store := NewStore(nil, func() bool { return true }) + pol := testCredentialPolicy(t, `{"defaultAction":"allow","egress":[{"action":"deny","target":"code.example.com"}]}`) + + _, err := store.Create(testCredentialVaultRequest(), pol) + require.ErrorContains(t, err, "not allowed by egress policy") +} + +func TestCredentialVaultRejectsReservedAndDuplicateHeaderNamesCaseInsensitively(t *testing.T) { + _, err := normalizeBinding(Binding{ + Name: "bad", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "apiKey", + Name: "Content-Length", + Credential: "token", + }, + }) + require.ErrorContains(t, err, "reserved credential header name") + + _, err = normalizeBinding(Binding{ + Name: "dupe", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "customHeaders", + Headers: []CustomHeaderEntry{ + {Name: "X-Access-Token", Credential: "a"}, + {Name: "x-access-token", Credential: "b"}, + }, + }, + }) + require.ErrorContains(t, err, "duplicate custom header name") +} + +func TestCredentialVaultRejectsInvalidSubstitution(t *testing.T) { + _, err := normalizeBinding(Binding{ + Name: "bad-substitution-surface", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "passthrough", + Substitutions: []Substitution{ + {Credential: "token", Placeholder: "__token__", In: []string{"cookie"}}, + }, + }, + }) + require.ErrorContains(t, err, "unsupported target surface") + + _, err = normalizeBinding(Binding{ + Name: "bad-substitution-placeholder", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "passthrough", + Substitutions: []Substitution{ + {Credential: "token", Placeholder: " ", In: []string{"body"}}, + }, + }, + }) + require.ErrorContains(t, err, "requires placeholder") +} + +func TestCredentialVaultPreservesSubstitutionPlaceholderWhitespace(t *testing.T) { + binding, err := normalizeBinding(Binding{ + Name: "literal-placeholder", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "passthrough", + Substitutions: []Substitution{ + {Credential: "token", Placeholder: " __token__ ", In: []string{" Body ", "body"}}, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, " __token__ ", binding.Auth.Substitutions[0].Placeholder) + require.Equal(t, []string{"body"}, binding.Auth.Substitutions[0].In) +} + +func TestCredentialVaultRejectsDuplicateSubstitutionPlaceholderSurface(t *testing.T) { + _, err := normalizeBinding(Binding{ + Name: "duplicate-placeholder-surface", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "passthrough", + Substitutions: []Substitution{ + {Credential: "primary", Placeholder: "__token__", In: []string{"body"}}, + {Credential: "secondary", Placeholder: "__token__", In: []string{"query", "body"}}, + }, + }, + }) + require.ErrorContains(t, err, `duplicates placeholder "__token__" on body surface`) +} + +func TestCredentialVaultRejectsPassthroughIgnoredFields(t *testing.T) { + for _, tc := range []struct { + name string + auth Auth + want string + }{ + { + name: "credential", + auth: Auth{Type: "passthrough", Credential: "api-token"}, + want: "does not accept credential", + }, + { + name: "name", + auth: Auth{Type: "passthrough", Name: "X-Token"}, + want: "does not accept name", + }, + { + name: "headers", + auth: Auth{ + Type: "passthrough", + Headers: []CustomHeaderEntry{{Name: "X-Token", Credential: "api-token"}}, + }, + want: "does not accept headers", + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := normalizeBinding(Binding{ + Name: "bad-passthrough", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: tc.auth, + }) + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestCredentialVaultRejectsNonFQDNBindingHosts(t *testing.T) { + for _, host := range []string{ + "api.example.com:443", + "api_example.com", + "localhost", + "*.localhost", + "*.example.com:443", + } { + _, err := normalizeBinding(Binding{ + Name: "bad-host", + Match: Match{Hosts: []string{host}}, + Auth: Auth{ + Type: "bearer", + Credential: "token", + }, + }) + require.Error(t, err, host) + } +} + +func TestCredentialVaultRejectsNonStandardPorts(t *testing.T) { + for _, tc := range []struct { + ports []int + wantErr bool + }{ + {[]int{8080}, true}, + {[]int{443, 8080}, true}, + {[]int{80, 443}, false}, + {[]int{443}, false}, + {[]int{80}, false}, + {nil, false}, + } { + _, err := normalizeBinding(Binding{ + Name: "test", + Match: Match{Hosts: []string{"api.example.com"}, Ports: tc.ports}, + Auth: Auth{Type: "bearer", Credential: "token"}, + }) + if tc.wantErr { + require.ErrorContains(t, err, "unsupported port", "ports=%v", tc.ports) + } else { + require.NoError(t, err, "ports=%v", tc.ports) + } + } +} + +func TestCredentialVaultPatchRejectsDeletingReferencedCredential(t *testing.T) { + store := NewStore(nil, func() bool { return true }) + pol := testCredentialPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + _, err := store.Create(testCredentialVaultRequest(), pol) + require.NoError(t, err) + + _, err = store.Patch(MutationRequest{ + Credentials: &CredentialMutationSet{Delete: []string{"gitlab-token"}}, + }, pol) + require.ErrorContains(t, err, "references unknown credential") + + state, err := store.Patch(MutationRequest{ + Bindings: &BindingMutationSet{Delete: []string{"gitlab-api"}}, + Credentials: &CredentialMutationSet{Delete: []string{"gitlab-token"}}, + }, pol) + require.NoError(t, err) + require.Empty(t, state.Credentials) + require.Empty(t, state.Bindings) +} + +func TestCredentialVaultRejectsUnknownSubstitutionCredential(t *testing.T) { + store := NewStore(nil, func() bool { return true }) + pol := testCredentialPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`) + + _, err := store.Create(CreateRequest{ + Credentials: nil, + Bindings: []Binding{ + { + Name: "missing-substitution-credential", + Match: Match{Hosts: []string{"code.example.com"}}, + Auth: Auth{ + Type: "passthrough", + Substitutions: []Substitution{ + {Credential: "missing", Placeholder: "__missing__", In: []string{"body"}}, + }, + }, + }, + }, + }, pol) + require.ErrorContains(t, err, "references unknown credential") +} + +func TestParseMitmproxyIgnoreHosts(t *testing.T) { + require.Equal(t, []string{`^example\.com$`, `.*\.internal$`}, parseMitmproxyIgnoreHosts(` +mode: + - transparent +ignore_hosts: + - '^example\.com$' + - ".*\.internal$" +listen_host: 127.0.0.1 +`)) + + require.Equal(t, []string{`^example\.com$`, `.*\.internal$`}, parseMitmproxyIgnoreHosts(` +ignore_hosts: ['^example\.com$', ".*\.internal$"] +`)) + + require.Nil(t, parseMitmproxyIgnoreHosts("ignore_hosts: []")) +} diff --git a/components/egress/pkg/dnsproxy/exempt.go b/components/egress/pkg/dnsproxy/exempt.go new file mode 100644 index 0000000..838743e --- /dev/null +++ b/components/egress/pkg/dnsproxy/exempt.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsproxy + +import ( + "net/netip" + "os" + "strings" + "sync" + + "github.com/alibaba/opensandbox/egress/pkg/constants" +) + +var ( + exemptListOnce sync.Once + exemptAddrs []netip.Addr + exemptSet map[netip.Addr]struct{} +) + +// ParseNameserverExemptList returns IPs from OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT (comma-separated); +// only single-IP entries are kept; list is parsed once and cached. Used for nft, iptables RETURN, and dialer. +func ParseNameserverExemptList() []netip.Addr { + exemptListOnce.Do(func() { parseNameserverExemptListUncached() }) + return exemptAddrs +} + +func parseNameserverExemptListUncached() { + raw := strings.TrimSpace(os.Getenv(constants.EnvNameserverExempt)) + if raw == "" { + exemptAddrs = nil + exemptSet = nil + return + } + set := make(map[netip.Addr]struct{}) + var out []netip.Addr + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if addr, err := netip.ParseAddr(s); err == nil { + if _, exists := set[addr]; exists { + continue + } + set[addr] = struct{}{} + out = append(out, addr) + } + } + exemptAddrs = out + exemptSet = set +} + +// If true, dialer omits SO_MARK; exempt dst also skips REDIRECT in iptables. +func UpstreamInExemptList(upstreamHost string) bool { + addr, err := netip.ParseAddr(upstreamHost) + if err != nil { + return false + } + ParseNameserverExemptList() // ensure cache is initialized + _, ok := exemptSet[addr] + return ok +} diff --git a/components/egress/pkg/dnsproxy/exempt_test.go b/components/egress/pkg/dnsproxy/exempt_test.go new file mode 100644 index 0000000..95a61d1 --- /dev/null +++ b/components/egress/pkg/dnsproxy/exempt_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsproxy + +import ( + "net/netip" + "sync" + "testing" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/stretchr/testify/require" +) + +func resetNameserverExemptCache(t *testing.T) { + t.Helper() + exemptAddrs = nil + exemptSet = nil + exemptListOnce = sync.Once{} +} + +func TestParseNameserverExemptList_IPOnly(t *testing.T) { + t.Setenv(constants.EnvNameserverExempt, "1.1.1.1, 2001:db8::1 ,invalid, 10.0.0.0/8, ,") + resetNameserverExemptCache(t) + + got := ParseNameserverExemptList() + want := []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2001:db8::1")} + require.Equal(t, want, got, "ParseNameserverExemptList() mismatch") + + // Cached result should stay the same on subsequent calls. + require.Equal(t, want, ParseNameserverExemptList(), "cached ParseNameserverExemptList() mismatch") +} + +func TestUpstreamInExemptList_IPOnly(t *testing.T) { + t.Setenv(constants.EnvNameserverExempt, "1.1.1.1,2001:db8::1") + resetNameserverExemptCache(t) + + require.True(t, UpstreamInExemptList("1.1.1.1"), "expected IPv4 upstream to be exempt") + require.True(t, UpstreamInExemptList("2001:db8::1"), "expected IPv6 upstream to be exempt") + require.False(t, UpstreamInExemptList("10.0.0.2"), "unexpected exempt match for non-listed IP") + require.False(t, UpstreamInExemptList("not-an-ip"), "invalid IP string should not match") +} + +func TestUpstreamInExemptList_CIDRIgnored(t *testing.T) { + t.Setenv(constants.EnvNameserverExempt, "10.0.0.0/24") + resetNameserverExemptCache(t) + + require.Empty(t, ParseNameserverExemptList(), "CIDR should be ignored in exempt list") + require.False(t, UpstreamInExemptList("10.0.0.5"), "CIDR should not make upstream exempt") +} diff --git a/components/egress/pkg/dnsproxy/proxy.go b/components/egress/pkg/dnsproxy/proxy.go new file mode 100644 index 0000000..6b2f7c7 --- /dev/null +++ b/components/egress/pkg/dnsproxy/proxy.go @@ -0,0 +1,577 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsproxy + +import ( + "context" + "fmt" + "net" + "net/netip" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/miekg/dns" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/events" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/nftables" + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/alibaba/opensandbox/egress/pkg/telemetry" + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/alibaba/opensandbox/internal/safego" +) + +const defaultListenAddr = "127.0.0.1:15353" + +type Proxy struct { + policyMu sync.RWMutex + userPolicy *policy.NetworkPolicy + effectivePolicy *policy.NetworkPolicy + alwaysDeny []policy.EgressRule + alwaysAllow []policy.EgressRule + listenAddr string + upstreams []string // ordered resolver chain from discovery (immutable after New) + upstreamMu sync.RWMutex + activeUpstreams []string // healthy subset; same order as upstreams; used for forwarding + upstreamProbeName string // wire name for probe (FQDN or "." for root) + upstreamProbeQType uint16 // dns.TypeA or dns.TypeNS etc. + upstreamProbeInterval time.Duration + upstreamExchangeTimeout time.Duration + servers []*dns.Server + shutdownOnce sync.Once + + // When set, called synchronously for allowed A/AAAA answers (dns+nft: program nft before client connects). + onResolved func(domain string, ips []nftables.ResolvedIP) + // Optional: async fan-out for denied lookups (e.g. webhook). + blockedBroadcaster *events.Broadcaster + + // Hosts whose successful outbound DNS log line should be suppressed (audit + // errors are still logged). Loaded once at startup; nil means "log all". + logSkip atomic.Pointer[policy.DomainSet] +} + +// New constructs the DNS proxy: discovers upstreams, default listen 127.0.0.1:15353 if listenAddr is "". +// alwaysDeny/alwaysAllow are merged via policy.MergeAlwaysOverlay; they are file/operator rules, not persisted by POST /policy. +func New(p *policy.NetworkPolicy, listenAddr string, alwaysDeny, alwaysAllow []policy.EgressRule) (*Proxy, error) { + if listenAddr == "" { + listenAddr = defaultListenAddr + } + if p == nil { + p = policy.DefaultDenyPolicy() + } + upstreams, err := DiscoverUpstreams() + if err != nil { + return nil, err + } + probeName, probeQType := upstreamProbeFromEnv() + proxy := &Proxy{ + listenAddr: listenAddr, + upstreams: upstreams, + activeUpstreams: append([]string(nil), upstreams...), + upstreamProbeName: probeName, + upstreamProbeQType: probeQType, + upstreamProbeInterval: upstreamProbeIntervalFromEnv(), + upstreamExchangeTimeout: upstreamExchangeTimeoutFromEnv(), + userPolicy: ensurePolicyDefaults(p), + alwaysDeny: append([]policy.EgressRule(nil), alwaysDeny...), + alwaysAllow: append([]policy.EgressRule(nil), alwaysAllow...), + } + proxy.refreshEffectivePolicy() + return proxy, nil +} + +func (p *Proxy) refreshEffectivePolicy() { + p.effectivePolicy = policy.MergeAlwaysOverlay(p.userPolicy, p.alwaysDeny, p.alwaysAllow) +} + +func upstreamExchangeTimeoutFromEnv() time.Duration { + s := strings.TrimSpace(os.Getenv(constants.EnvDNSUpstreamTimeout)) + if s == "" { + return time.Duration(constants.DefaultDNSUpstreamTimeoutSec) * time.Second + } + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return time.Duration(constants.DefaultDNSUpstreamTimeoutSec) * time.Second + } + if n > 120 { + n = 120 + } + return time.Duration(n) * time.Second +} + +func (p *Proxy) Start(ctx context.Context) error { + handler := dns.HandlerFunc(p.serveDNS) + + udpServer := &dns.Server{Addr: p.listenAddr, Net: "udp", Handler: handler} + tcpServer := &dns.Server{Addr: p.listenAddr, Net: "tcp", Handler: handler} + p.servers = []*dns.Server{udpServer, tcpServer} + + readyCh := make(chan struct{}, len(p.servers)) + errCh := make(chan error, len(p.servers)) + for _, srv := range p.servers { + s := srv + s.NotifyStartedFunc = func() { readyCh <- struct{}{} } + safego.Go(func() { + if err := s.ListenAndServe(); err != nil { + errCh <- err + } + }) + } + + // Wait for all servers (UDP + TCP) to bind, or fail fast on error. + for i := 0; i < len(p.servers); i++ { + select { + case err := <-errCh: + return fmt.Errorf("dns proxy failed: %w", err) + case <-readyCh: + } + } + + safego.Go(func() { p.runUpstreamProbes(ctx) }) + + return nil +} + +// Shutdown stops UDP and TCP DNS listeners. Safe to call more than once. +func (p *Proxy) Shutdown() error { + var outErr error + p.shutdownOnce.Do(func() { + for _, srv := range p.servers { + if e := srv.Shutdown(); e != nil && outErr == nil { + outErr = e + } + } + }) + return outErr +} + +func (p *Proxy) serveDNS(w dns.ResponseWriter, r *dns.Msg) { + if len(r.Question) == 0 { + _ = w.WriteMsg(new(dns.Msg)) + return + } + q := r.Question[0] + domain := q.Name + host := normalizeDNSHost(domain) + + p.policyMu.RLock() + currentPolicy := p.effectivePolicy + p.policyMu.RUnlock() + if currentPolicy != nil && currentPolicy.Evaluate(domain) == policy.ActionDeny { + telemetry.RecordDNSDenied() + p.publishBlocked(domain) + resp := new(dns.Msg) + resp.SetRcode(r, dns.RcodeNameError) + _ = w.WriteMsg(resp) + return + } + + start := time.Now() + resp, err := p.forward(r) + elapsed := time.Since(start).Seconds() + if err != nil { + telemetry.RecordDNSForward(elapsed) + logOutboundDNS(host, nil, "", err.Error()) + fail := new(dns.Msg) + fail.SetRcode(r, dns.RcodeServerFailure) + _ = w.WriteMsg(fail) + return + } + telemetry.RecordDNSForward(elapsed) + if !p.shouldSkipOutboundLog(host) { + logOutboundDNS(host, resolvedIPStrings(resp), "", "") + } + p.maybeNotifyResolved(domain, resp) + _ = w.WriteMsg(resp) +} + +// SetLogSkip replaces the set of hosts whose successful DNS outbound log line +// is suppressed. Passing nil or an empty slice restores the default of logging +// every outbound. Safe to call concurrently; reads use an atomic pointer. +func (p *Proxy) SetLogSkip(patterns []string) { + p.logSkip.Store(policy.NewDomainSet(patterns)) +} + +func (p *Proxy) shouldSkipOutboundLog(host string) bool { + ds := p.logSkip.Load() + if ds == nil || ds.Empty() { + return false + } + return ds.Match(host) +} + +// maybeNotifyResolved calls onResolved before w.WriteMsg so dynamic nft allows are installed +// before the client receives the answer and may open a connection. +func (p *Proxy) maybeNotifyResolved(domain string, resp *dns.Msg) { + if p.onResolved == nil { + return + } + ips := extractResolvedIPs(resp) + if len(ips) == 0 { + return + } + p.onResolved(domain, ips) +} + +func (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) { + list := p.forwardUpstreams() + var lastErr error + for _, upstream := range list { + const upstreamUDPSize = 4096 + query := r.Copy() + if query.IsEdns0() == nil { + query.SetEdns0(upstreamUDPSize, false) + } + c := &dns.Client{ + Timeout: p.upstreamExchangeTimeout, + Dialer: p.dialerForUpstream(upstream), + UDPSize: upstreamUDPSize, + } + resp, _, err := c.Exchange(query, upstream) + if err != nil { + lastErr = err + log.Warnf("[dns] upstream %s exchange error: %v", upstream, err) + continue + } + if resp == nil { + lastErr = fmt.Errorf("nil response from %s", upstream) + continue + } + if tryNext, reason := p.shouldFailoverAfterResponse(resp); tryNext { + lastErr = fmt.Errorf("%s from %s", reason, upstream) + log.Warnf("[dns] upstream %s: %s; trying next", upstream, reason) + continue + } + return resp, nil + } + if lastErr != nil { + return nil, lastErr + } + return nil, fmt.Errorf("no upstream resolvers configured") +} + +// shouldFailoverAfterResponse: treat NXDOMAIN and NOERROR as final (no retry). Other rcodes may +// move to the next upstream (e.g. SERVFAIL). +func (p *Proxy) shouldFailoverAfterResponse(resp *dns.Msg) (tryNext bool, reason string) { + if resp == nil { + return true, "nil response" + } + switch resp.Rcode { + case dns.RcodeNameError: + return false, "" + case dns.RcodeSuccess: + return false, "" + default: + rcStr := dns.RcodeToString[resp.Rcode] + if rcStr == "" { + rcStr = fmt.Sprintf("rcode %d", resp.Rcode) + } + return true, rcStr + } +} + +func (p *Proxy) UpstreamHost() string { + list := p.forwardUpstreams() + if len(list) == 0 { + return "" + } + host, _, err := net.SplitHostPort(list[0]) + if err != nil { + return "" + } + return host +} + +// UpdatePolicy replaces the user policy from POST/GET /policy (not the always file overlay). Nil → default deny-all. +func (p *Proxy) UpdatePolicy(newPolicy *policy.NetworkPolicy) { + p.policyMu.Lock() + defer p.policyMu.Unlock() + + p.userPolicy = ensurePolicyDefaults(newPolicy) + p.refreshEffectivePolicy() +} + +// UpdateAlwaysRules replaces the always-deny/always-allow file overlay used only for evaluation (merged in refreshEffectivePolicy). +func (p *Proxy) UpdateAlwaysRules(alwaysDeny, alwaysAllow []policy.EgressRule) { + p.policyMu.Lock() + defer p.policyMu.Unlock() + + p.alwaysDeny = append([]policy.EgressRule(nil), alwaysDeny...) + p.alwaysAllow = append([]policy.EgressRule(nil), alwaysAllow...) + p.refreshEffectivePolicy() +} + +// CurrentPolicy is the last user policy from the API, without always file overlay in the struct (overlay is in effectivePolicy). +func (p *Proxy) CurrentPolicy() *policy.NetworkPolicy { + p.policyMu.RLock() + defer p.policyMu.RUnlock() + + return p.userPolicy +} + +// SetOnResolved registers the dns+nft path (nil in dns-only). Invoked on the same goroutine as serveDNS, before WriteMsg. +func (p *Proxy) SetOnResolved(fn func(domain string, ips []nftables.ResolvedIP)) { + p.onResolved = fn +} + +// SetBlockedBroadcaster wires the optional publisher for policy-denied lookups. +func (p *Proxy) SetBlockedBroadcaster(b *events.Broadcaster) { + p.blockedBroadcaster = b +} + +func (p *Proxy) publishBlocked(domain string) { + if p.blockedBroadcaster == nil { + return + } + normalized := strings.ToLower(strings.TrimSuffix(domain, ".")) + if normalized == "" { + return + } + + p.blockedBroadcaster.Publish(events.BlockedEvent{ + Hostname: normalized, + Timestamp: time.Now().UTC(), + }) +} + +// extractResolvedIPs collects A/AAAA from resp.Answer with TTLs for dynamic nft elements. +func extractResolvedIPs(resp *dns.Msg) []nftables.ResolvedIP { + if resp == nil || len(resp.Answer) == 0 { + return nil + } + + var out []nftables.ResolvedIP + for _, rr := range resp.Answer { + switch v := rr.(type) { + case *dns.A: + if v.A == nil { + continue + } + addr, err := netip.ParseAddr(v.A.String()) + if err != nil { + continue + } + out = append(out, nftables.ResolvedIP{Addr: addr, TTL: time.Duration(v.Hdr.Ttl) * time.Second}) + case *dns.AAAA: + if v.AAAA == nil { + continue + } + addr, err := netip.ParseAddr(v.AAAA.String()) + if err != nil { + continue + } + out = append(out, nftables.ResolvedIP{Addr: addr, TTL: time.Duration(v.Hdr.Ttl) * time.Second}) + } + } + return out +} + +const fallbackUpstream = "8.8.8.8:53" + +// DiscoverUpstreams is env OPENSANDBOX_EGRESS_DNS_UPSTREAM if set, else /etc/resolv.conf (with caps/fallbacks). +func DiscoverUpstreams() ([]string, error) { + raw := strings.TrimSpace(os.Getenv(constants.EnvDNSUpstream)) + if raw != "" { + return parseEnvDNSUpstreams(raw) + } + return discoverUpstreamsFromResolv() +} + +// parseEnvDNSUpstreams splits OPENSANDBOX_EGRESS_DNS_UPSTREAM (comma-separated); each entry must pass normalizeEnvUpstreamAddr. +func parseEnvDNSUpstreams(raw string) ([]string, error) { + var out []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + addr, err := normalizeEnvUpstreamAddr(part) + if err != nil { + return nil, fmt.Errorf("%s: %w", constants.EnvDNSUpstream, err) + } + out = append(out, addr) + } + if len(out) == 0 { + return nil, fmt.Errorf("%s must list at least one upstream resolver", constants.EnvDNSUpstream) + } + return dedupeUpstreamAddrs(out), nil +} + +// normalizeEnvUpstreamAddr requires a literal IP (optional :port). Resolving a hostname to :53 would hit +// OUTPUT REDIRECT to this proxy again and recurse. +func normalizeEnvUpstreamAddr(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", fmt.Errorf("empty upstream address") + } + if host, port, err := net.SplitHostPort(s); err == nil { + if port == "" { + return "", fmt.Errorf("invalid port in %q", s) + } + if _, err := netip.ParseAddr(host); err != nil { + return "", fmt.Errorf("host %q must be a literal IP address, not a hostname (avoids DNS self-recursion with REDIRECT)", host) + } + return net.JoinHostPort(host, port), nil + } + if strings.HasPrefix(s, "[") { + if !strings.HasSuffix(s, "]") { + return "", fmt.Errorf("invalid bracketed IPv6 %q", s) + } + inner := strings.TrimPrefix(strings.TrimSuffix(s, "]"), "[") + if _, err := netip.ParseAddr(inner); err != nil { + return "", fmt.Errorf("invalid IP inside brackets %q", s) + } + return net.JoinHostPort(inner, "53"), nil + } + addr, err := netip.ParseAddr(s) + if err != nil { + return "", fmt.Errorf("upstream %q must be a literal IP address, not a hostname: %w", s, err) + } + return net.JoinHostPort(addr.String(), "53"), nil +} + +func discoverUpstreamsFromResolv() ([]string, error) { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || len(cfg.Servers) == 0 { + if err != nil { + log.Warnf("[dns] fallback upstream resolver due to error: %v", err) + } + return []string{fallbackUpstream}, nil + } + port := cfg.Port + if port == "" { + port = "53" + } + var nonLoop, loop []string + for _, s := range cfg.Servers { + addr := net.JoinHostPort(s, port) + if ip := net.ParseIP(s); ip != nil && ip.IsLoopback() { + loop = append(loop, addr) + continue + } + nonLoop = append(nonLoop, addr) + } + out := append(nonLoop, loop...) + if len(out) == 0 { + out = []string{net.JoinHostPort(cfg.Servers[0], port)} + } + if len(out) > constants.ResolvNameserverCap { + out = out[:constants.ResolvNameserverCap] + } + return dedupeUpstreamAddrs(out), nil +} + +func dedupeUpstreamAddrs(addrs []string) []string { + seen := make(map[string]struct{}, len(addrs)) + var out []string + for _, a := range addrs { + if _, ok := seen[a]; ok { + continue + } + seen[a] = struct{}{} + out = append(out, a) + } + return out +} + +// AllowIPsFromUpstreamAddrs collects literal resolver IPs from the host:port upstream list (nft allow, deduped). +func AllowIPsFromUpstreamAddrs(upstreams []string) []netip.Addr { + var out []netip.Addr + seen := make(map[netip.Addr]struct{}) + for _, a := range upstreams { + host, _, err := net.SplitHostPort(a) + if err != nil { + continue + } + ip, err := netip.ParseAddr(host) + if err != nil { + continue + } + if _, ok := seen[ip]; ok { + continue + } + seen[ip] = struct{}{} + out = append(out, ip) + } + return out +} + +// ResolvNameserverIPs parses resolvPath nameserver lines; used to allow the system resolver IPs in nft +// and align client vs. proxy forward path. +func ResolvNameserverIPs(resolvPath string) ([]netip.Addr, error) { + cfg, err := dns.ClientConfigFromFile(resolvPath) + if err != nil || len(cfg.Servers) == 0 { + return nil, nil + } + var out []netip.Addr + for _, s := range cfg.Servers { + ip, err := netip.ParseAddr(s) + if err != nil { + continue + } + out = append(out, ip) + } + return out, nil +} + +func normalizeDNSHost(domain string) string { + return strings.ToLower(strings.TrimSuffix(domain, ".")) +} + +func resolvedIPStrings(resp *dns.Msg) []string { + ri := extractResolvedIPs(resp) + if len(ri) == 0 { + return nil + } + out := make([]string, 0, len(ri)) + for _, x := range ri { + out = append(out, x.Addr.String()) + } + return out +} + +func logOutboundDNS(host string, ips []string, peer string, errStr string) { + fields := []slogger.Field{ + {Key: "opensandbox.event", Value: "egress.outbound"}, + } + if host != "" { + fields = append(fields, slogger.Field{Key: "target.host", Value: host}) + } + if peer != "" { + fields = append(fields, slogger.Field{Key: "peer", Value: peer}) + } + if len(ips) > 0 { + fields = append(fields, slogger.Field{Key: "target.ips", Value: ips}) + } + if errStr != "" { + fields = append(fields, slogger.Field{Key: "error", Value: errStr}) + } + log.Logger.With(fields...).Infof("egress outbound") +} + +func ensurePolicyDefaults(p *policy.NetworkPolicy) *policy.NetworkPolicy { + if p == nil { + return policy.DefaultDenyPolicy() + } + if p.DefaultAction == "" { + p.DefaultAction = policy.ActionDeny + } + return p +} diff --git a/components/egress/pkg/dnsproxy/proxy_linux.go b/components/egress/pkg/dnsproxy/proxy_linux.go new file mode 100644 index 0000000..33a45c2 --- /dev/null +++ b/components/egress/pkg/dnsproxy/proxy_linux.go @@ -0,0 +1,60 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package dnsproxy + +import ( + "net" + "sync" + "syscall" + + "golang.org/x/sys/unix" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +var exemptDialerLogOnce sync.Once + +// dialerForUpstream sets SO_MARK so iptables can RETURN marked packets (bypass +// redirect for proxy's own upstream DNS queries). When upstream is in the nameserver +// exempt list, returns a plain dialer (no mark) so upstream traffic follows normal +// routing (e.g. via tun); iptables still does not redirect by destination exempt. +func (p *Proxy) dialerForUpstream(upstreamAddr string) *net.Dialer { + host, _, err := net.SplitHostPort(upstreamAddr) + if err != nil { + host = upstreamAddr + } + if UpstreamInExemptList(host) { + exemptDialerLogOnce.Do(func() { + log.Infof("[dns] upstream %s in nameserver exempt list, not setting SO_MARK", host) + }) + return &net.Dialer{Timeout: p.upstreamExchangeTimeout} + } + + return &net.Dialer{ + Timeout: p.upstreamExchangeTimeout, + Control: func(network, address string, c syscall.RawConn) error { + var opErr error + if err := c.Control(func(fd uintptr) { + opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue) + }); err != nil { + return err + } + return opErr + }, + } +} diff --git a/components/egress/pkg/dnsproxy/proxy_other.go b/components/egress/pkg/dnsproxy/proxy_other.go new file mode 100644 index 0000000..5bf6d11 --- /dev/null +++ b/components/egress/pkg/dnsproxy/proxy_other.go @@ -0,0 +1,27 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package dnsproxy + +import ( + "net" +) + +// No SO_MARK: plain dialer (UNIX/Windows test builds; Linux path is in proxy_linux.go). +func (p *Proxy) dialerForUpstream(upstreamAddr string) *net.Dialer { + _ = upstreamAddr + return &net.Dialer{Timeout: p.upstreamExchangeTimeout} +} diff --git a/components/egress/pkg/dnsproxy/proxy_test.go b/components/egress/pkg/dnsproxy/proxy_test.go new file mode 100644 index 0000000..70b4312 --- /dev/null +++ b/components/egress/pkg/dnsproxy/proxy_test.go @@ -0,0 +1,238 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsproxy + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/nftables" + "github.com/alibaba/opensandbox/egress/pkg/policy" +) + +func TestProxyUpdatePolicy(t *testing.T) { + proxy, err := New(nil, "127.0.0.1:15353", nil, nil) + require.NoError(t, err, "init proxy") + + require.NotNil(t, proxy.CurrentPolicy(), "expected default deny policy (non-nil)") + require.Equal(t, policy.ActionDeny, proxy.CurrentPolicy().Evaluate("example.com."), "expected default deny") + + pol, err := policy.ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"example.com"}]}`) + require.NoError(t, err, "parse policy") + + proxy.UpdatePolicy(pol) + require.NotNil(t, proxy.CurrentPolicy(), "expected policy after update") + require.Equal(t, policy.ActionAllow, proxy.CurrentPolicy().Evaluate("example.com."), "policy evaluation mismatch") + + proxy.UpdatePolicy(nil) + require.NotNil(t, proxy.CurrentPolicy(), "expected default deny policy after clearing") + require.Equal(t, policy.ActionDeny, proxy.CurrentPolicy().Evaluate("example.com."), "expected default deny after clearing") +} + +func TestProxyAlwaysOverlayPrecedence(t *testing.T) { + deny, err := policy.ParseValidatedEgressRule(policy.ActionDeny, "nope.test") + require.NoError(t, err) + pol, err := policy.ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"nope.test"}]}`) + require.NoError(t, err) + proxy, err := New(pol, "127.0.0.1:15353", []policy.EgressRule{deny}, nil) + require.NoError(t, err) + require.Equal(t, policy.ActionAllow, proxy.CurrentPolicy().Evaluate("nope.test."), "user policy without overlay") + require.Equal(t, policy.ActionDeny, proxy.effectivePolicy.Evaluate("nope.test."), "effective policy includes always deny") +} + +func TestExtractResolvedIPs(t *testing.T) { + msg := new(dns.Msg) + msg.Answer = []dns.RR{ + &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 120}, A: net.ParseIP("1.2.3.4")}, + &dns.AAAA{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 60}, AAAA: net.ParseIP("2001:db8::1")}, + &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 90}, A: net.ParseIP("5.6.7.8")}, + } + ips := extractResolvedIPs(msg) + require.Len(t, ips, 3, "expected 3 IPs") + // Order follows Answer; check first A and AAAA + require.Equal(t, "1.2.3.4", ips[0].Addr.String(), "first IP mismatch") + require.Equal(t, 120*time.Second, ips[0].TTL, "first IP TTL mismatch") + require.Equal(t, "2001:db8::1", ips[1].Addr.String(), "second IP mismatch") + require.Equal(t, 60*time.Second, ips[1].TTL, "second IP TTL mismatch") + require.Equal(t, "5.6.7.8", ips[2].Addr.String(), "third IP mismatch") + require.Equal(t, 90*time.Second, ips[2].TTL, "third IP TTL mismatch") +} + +func TestExtractResolvedIPs_EmptyOrNil(t *testing.T) { + require.Nil(t, extractResolvedIPs(nil), "nil msg: expected nil") + msg := new(dns.Msg) + require.Nil(t, extractResolvedIPs(msg), "empty answer: expected nil") + msg.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: "x."}, Target: "y."}} + require.Nil(t, extractResolvedIPs(msg), "CNAME only: expected nil") +} + +func TestForwardAddsEDNS0BufferSize(t *testing.T) { + t.Cleanup(func() { resetNameserverExemptCache(t) }) + t.Setenv(constants.EnvNameserverExempt, "127.0.0.1") + resetNameserverExemptCache(t) + + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + seen := make(chan uint16, 1) + server := &dns.Server{ + PacketConn: conn, + Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + opt := r.IsEdns0() + require.NotNil(t, opt) + seen <- opt.UDPSize() + + resp := new(dns.Msg) + resp.SetReply(r) + resp.Answer = []dns.RR{ + &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("1.2.3.4")}, + } + _ = w.WriteMsg(resp) + }), + } + go func() { _ = server.ActivateAndServe() }() + t.Cleanup(func() { _ = server.Shutdown() }) + + proxy := &Proxy{ + upstreams: []string{conn.LocalAddr().String()}, + activeUpstreams: []string{conn.LocalAddr().String()}, + upstreamExchangeTimeout: time.Second, + } + query := new(dns.Msg) + query.SetQuestion("example.com.", dns.TypeA) + + resp, err := proxy.forward(query) + require.NoError(t, err) + require.Len(t, resp.Answer, 1) + require.Equal(t, uint16(4096), <-seen) +} + +func TestSetOnResolved(t *testing.T) { + proxy, err := New(policy.DefaultDenyPolicy(), "", nil, nil) + require.NoError(t, err) + var called bool + var capturedDomain string + var capturedIPs []nftables.ResolvedIP + proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { + called = true + capturedDomain = domain + capturedIPs = ips + }) + require.NotNil(t, proxy.onResolved, "SetOnResolved did not set callback") + proxy.SetOnResolved(nil) + require.Nil(t, proxy.onResolved, "SetOnResolved(nil) did not clear callback") + _ = called + _ = capturedDomain + _ = capturedIPs +} + +func TestMaybeNotifyResolved_CallsCallbackWhenAOrAAAA(t *testing.T) { + proxy, err := New(policy.DefaultDenyPolicy(), "", nil, nil) + require.NoError(t, err) + ch := make(chan struct { + domain string + ips []nftables.ResolvedIP + }, 1) + proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { + ch <- struct { + domain string + ips []nftables.ResolvedIP + }{domain, ips} + }) + + msg := new(dns.Msg) + msg.Answer = []dns.RR{ + &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 120}, A: net.ParseIP("1.2.3.4")}, + } + proxy.maybeNotifyResolved("example.com.", msg) + + select { + case got := <-ch: + require.Equal(t, "example.com.", got.domain, "domain mismatch") + require.Len(t, got.ips, 1, "expected one resolved IP") + require.Equal(t, "1.2.3.4", got.ips[0].Addr.String(), "resolved IP mismatch") + case <-time.After(2 * time.Second): + require.FailNow(t, "callback was not invoked") + } +} + +func TestMaybeNotifyResolved_NoCallWhenOnResolvedNil(t *testing.T) { + proxy, err := New(policy.DefaultDenyPolicy(), "", nil, nil) + require.NoError(t, err) + msg := new(dns.Msg) + msg.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: "x.", Ttl: 60}, A: net.ParseIP("10.0.0.1")}} + proxy.maybeNotifyResolved("x.", msg) + // No callback set; should not panic. No assertion needed. +} + +func TestMaybeNotifyResolved_NoCallWhenNoAOrAAAA(t *testing.T) { + proxy, err := New(policy.DefaultDenyPolicy(), "", nil, nil) + require.NoError(t, err) + ch := make(chan struct { + domain string + ips []nftables.ResolvedIP + }, 1) + proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { + ch <- struct { + domain string + ips []nftables.ResolvedIP + }{domain, ips} + }) + + msg := new(dns.Msg) + msg.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: "x."}, Target: "y."}} + proxy.maybeNotifyResolved("x.", msg) + + select { + case <-ch: + require.FailNow(t, "callback should not be invoked when resp has no A/AAAA") + case <-time.After(200 * time.Millisecond): + // Expected: no callback + } +} + +func TestProxyShouldSkipOutboundLog_Default(t *testing.T) { + p := &Proxy{} + require.False(t, p.shouldSkipOutboundLog("metadata.internal"), + "default (no SetLogSkip call) must preserve current behavior: log every outbound") +} + +func TestProxyShouldSkipOutboundLog_MatchesAndMisses(t *testing.T) { + p := &Proxy{} + p.SetLogSkip([]string{"metadata.internal", "*.cluster.local"}) + + require.True(t, p.shouldSkipOutboundLog("metadata.internal"), "exact pattern hit") + require.True(t, p.shouldSkipOutboundLog("svc.cluster.local"), "wildcard subdomain hit") + require.True(t, p.shouldSkipOutboundLog("METADATA.INTERNAL."), "case + trailing dot normalised") + require.False(t, p.shouldSkipOutboundLog("cluster.local"), + "wildcard *.cluster.local must not match bare cluster.local") + require.False(t, p.shouldSkipOutboundLog("evil.com"), "non-listed host must not be skipped") +} + +func TestProxyShouldSkipOutboundLog_ClearedByEmptyList(t *testing.T) { + p := &Proxy{} + p.SetLogSkip([]string{"metadata.internal"}) + require.True(t, p.shouldSkipOutboundLog("metadata.internal")) + + p.SetLogSkip(nil) + require.False(t, p.shouldSkipOutboundLog("metadata.internal"), + "clearing the list must re-enable logging for all hosts") +} diff --git a/components/egress/pkg/dnsproxy/upstream.go b/components/egress/pkg/dnsproxy/upstream.go new file mode 100644 index 0000000..5dd5034 --- /dev/null +++ b/components/egress/pkg/dnsproxy/upstream.go @@ -0,0 +1,153 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsproxy + +import ( + "context" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/miekg/dns" + + "github.com/alibaba/opensandbox/internal/safego" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +const defaultUpstreamProbeInterval = 30 * time.Second + +func upstreamProbeIntervalFromEnv() time.Duration { + s := strings.TrimSpace(os.Getenv(constants.EnvDNSUpstreamProbeIntervalSec)) + if s == "" { + return defaultUpstreamProbeInterval + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 { + return defaultUpstreamProbeInterval + } + if n > 3600 { + n = 3600 + } + return time.Duration(n) * time.Second +} + +// upstreamProbeFromEnv returns the DNS question used for upstream liveness checks. +// Default is root IN NS (primers/recursors answer without resolving a public TLD). +// Set OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE to an FQDN that your resolvers always +// answer (e.g. split-horizon internal name) when the default is inappropriate. +func upstreamProbeFromEnv() (name string, qtype uint16) { + raw := strings.TrimSpace(os.Getenv(constants.EnvDNSUpstreamProbe)) + if raw == "" || raw == "." { + return ".", dns.TypeNS + } + return dns.Fqdn(raw), dns.TypeA +} + +func (p *Proxy) runUpstreamProbes(ctx context.Context) { + p.probeUpstreams() + + t := time.NewTicker(p.upstreamProbeInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + p.probeUpstreams() + } + } +} + +// forwardUpstreams returns the ordered list used for DNS forwarding: last healthy probe +// results when non-empty; otherwise the configured chain (e.g. before first probe). +func (p *Proxy) forwardUpstreams() []string { + p.upstreamMu.RLock() + active := p.activeUpstreams + p.upstreamMu.RUnlock() + if len(active) > 0 { + return active + } + return p.upstreams +} + +// probeUpstreams checks each configured resolver in parallel and refreshes activeUpstreams. +// If every probe fails, the full upstream list is kept so forwarding still attempts all resolvers. +func (p *Proxy) probeUpstreams() { + all := p.upstreams + if len(all) == 0 { + return + } + + timeout := probeExchangeTimeout(p.upstreamExchangeTimeout) + healthy := make([]bool, len(all)) + var wg sync.WaitGroup + for i := range all { + wg.Add(1) + idx := i + addr := all[i] + safego.Go(func() { + defer wg.Done() + healthy[idx] = p.probeOneUpstream(addr, timeout) + }) + } + wg.Wait() + + var active []string + for i := range all { + if healthy[i] { + active = append(active, all[i]) + } + } + if len(active) == 0 { + log.Warnf("[dns] all upstream probes failed; using full upstream list for forwarding") + active = append([]string(nil), all...) + } + + p.upstreamMu.Lock() + p.activeUpstreams = active + p.upstreamMu.Unlock() +} + +func probeExchangeTimeout(upstreamTimeout time.Duration) time.Duration { + const maxProbe = 2 * time.Second + if upstreamTimeout <= 0 { + return maxProbe + } + if upstreamTimeout > maxProbe { + return maxProbe + } + return upstreamTimeout +} + +func (p *Proxy) probeOneUpstream(addr string, timeout time.Duration) bool { + m := new(dns.Msg) + m.SetQuestion(p.upstreamProbeName, p.upstreamProbeQType) + m.RecursionDesired = true + + c := &dns.Client{ + Timeout: timeout, + Dialer: p.dialerForUpstream(addr), + } + resp, _, err := c.Exchange(m, addr) + if err != nil { + log.Errorf("[dns] upstream probe %s failed: %v", addr, err) + return false + } + return resp != nil +} diff --git a/components/egress/pkg/dnsproxy/upstream_test.go b/components/egress/pkg/dnsproxy/upstream_test.go new file mode 100644 index 0000000..00e7730 --- /dev/null +++ b/components/egress/pkg/dnsproxy/upstream_test.go @@ -0,0 +1,154 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsproxy + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/egress/pkg/constants" +) + +func TestUpstreamProbeIntervalFromEnv(t *testing.T) { + t.Setenv(constants.EnvDNSUpstreamProbeIntervalSec, "") + require.Equal(t, defaultUpstreamProbeInterval, upstreamProbeIntervalFromEnv()) + + t.Setenv(constants.EnvDNSUpstreamProbeIntervalSec, "5") + require.Equal(t, 5*time.Second, upstreamProbeIntervalFromEnv()) + + t.Setenv(constants.EnvDNSUpstreamProbeIntervalSec, "not-a-number") + require.Equal(t, defaultUpstreamProbeInterval, upstreamProbeIntervalFromEnv()) +} + +func TestUpstreamProbeFromEnv(t *testing.T) { + t.Setenv(constants.EnvDNSUpstreamProbe, "") + n, qt := upstreamProbeFromEnv() + require.Equal(t, ".", n) + require.Equal(t, dns.TypeNS, qt) + + t.Setenv(constants.EnvDNSUpstreamProbe, ".") + n, qt = upstreamProbeFromEnv() + require.Equal(t, ".", n) + require.Equal(t, dns.TypeNS, qt) + + t.Setenv(constants.EnvDNSUpstreamProbe, "intranet.corp") + n, qt = upstreamProbeFromEnv() + require.Equal(t, "intranet.corp.", n) + require.Equal(t, dns.TypeA, qt) +} + +func TestNormalizeEnvUpstreamAddr(t *testing.T) { + got, err := normalizeEnvUpstreamAddr("8.8.8.8") + require.NoError(t, err) + require.Equal(t, "8.8.8.8:53", got) + + got, err = normalizeEnvUpstreamAddr("1.1.1.1:5353") + require.NoError(t, err) + require.Equal(t, "1.1.1.1:5353", got) + + got, err = normalizeEnvUpstreamAddr("2001:db8::1") + require.NoError(t, err) + require.Equal(t, "[2001:db8::1]:53", got) + + got, err = normalizeEnvUpstreamAddr("[2001:db8::2]:853") + require.NoError(t, err) + require.Equal(t, "[2001:db8::2]:853", got) + + got, err = normalizeEnvUpstreamAddr("[2001:db8::3]") + require.NoError(t, err) + require.Equal(t, "[2001:db8::3]:53", got) + + _, err = normalizeEnvUpstreamAddr("") + require.Error(t, err) + + _, err = normalizeEnvUpstreamAddr("dns.google") + require.Error(t, err) + + _, err = normalizeEnvUpstreamAddr("dns.google:53") + require.Error(t, err) +} + +func TestParseEnvDNSUpstreams(t *testing.T) { + got, err := parseEnvDNSUpstreams("8.8.8.8,1.1.1.1") + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8:53", "1.1.1.1:53"}, got) + + got, err = parseEnvDNSUpstreams("8.8.8.8") + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8:53"}, got) + + got, err = parseEnvDNSUpstreams("8.8.8.8, 8.8.8.8, 1.1.1.1") + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8:53", "1.1.1.1:53"}, got) + + _, err = parseEnvDNSUpstreams("dns.google,8.8.8.8") + require.Error(t, err) +} + +func TestAllowIPsFromUpstreamAddrs(t *testing.T) { + ips := AllowIPsFromUpstreamAddrs([]string{"8.8.8.8:53", "1.1.1.1:53", "resolver.example.com:53"}) + require.Len(t, ips, 2) + require.Equal(t, "8.8.8.8", ips[0].String()) + require.Equal(t, "1.1.1.1", ips[1].String()) +} + +func TestShouldFailoverAfterResponse(t *testing.T) { + p2 := &Proxy{upstreams: []string{"198.51.100.254:53", "8.8.8.8:53"}} + + emptyOK := new(dns.Msg) + emptyOK.Rcode = dns.RcodeSuccess + try, _ := p2.shouldFailoverAfterResponse(emptyOK) + require.False(t, try, "empty NOERROR should not failover") + + try, _ = p2.shouldFailoverAfterResponse(emptyOK) + require.False(t, try, "empty NOERROR on last upstream should not failover") + + emptyNODATA := new(dns.Msg) + emptyNODATA.Rcode = dns.RcodeSuccess + emptyNODATA.Ns = []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, + Ns: "ns1.example.com.", + Mbox: "hostmaster.example.com.", + Serial: 1, + Refresh: 60, + Retry: 60, + Expire: 60, + Minttl: 60, + }, + } + try, _ = p2.shouldFailoverAfterResponse(emptyNODATA) + require.False(t, try, "A/AAAA NODATA with authority should not failover") + + withA := new(dns.Msg) + withA.Rcode = dns.RcodeSuccess + withA.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: "x."}, A: net.ParseIP("1.1.1.1")}} + try, _ = p2.shouldFailoverAfterResponse(withA) + require.False(t, try) + + nx := new(dns.Msg) + nx.Rcode = dns.RcodeNameError + try, _ = p2.shouldFailoverAfterResponse(nx) + require.False(t, try) + + sf := new(dns.Msg) + sf.Rcode = dns.RcodeServerFailure + try, _ = p2.shouldFailoverAfterResponse(sf) + require.True(t, try) +} diff --git a/components/egress/pkg/events/broadcaster.go b/components/egress/pkg/events/broadcaster.go new file mode 100644 index 0000000..2372b4c --- /dev/null +++ b/components/egress/pkg/events/broadcaster.go @@ -0,0 +1,124 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package events + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/internal/safego" +) + +const defaultQueueSize = 128 + +// BlockedEvent is emitted when the DNS path denies a query. +type BlockedEvent struct { + Hostname string `json:"hostname"` + Timestamp time.Time `json:"timestamp"` +} + +type Subscriber interface { + HandleBlocked(ctx context.Context, ev BlockedEvent) +} + +type BroadcasterConfig struct { + QueueSize int +} + +// Broadcaster: per-subscriber buffered channel; full buffer drops and logs a warning. +type Broadcaster struct { + ctx context.Context + cancel context.CancelFunc + + mu sync.RWMutex + subscribers []chan BlockedEvent + queueSize int + closed atomic.Bool +} + +func NewBroadcaster(ctx context.Context, cfg BroadcasterConfig) *Broadcaster { + if cfg.QueueSize <= 0 { + cfg.QueueSize = defaultQueueSize + } + cctx, cancel := context.WithCancel(ctx) + return &Broadcaster{ + ctx: cctx, + cancel: cancel, + queueSize: cfg.QueueSize, + } +} + +func (b *Broadcaster) AddSubscriber(sub Subscriber) { + if sub == nil { + return + } + ch := make(chan BlockedEvent, b.queueSize) + + b.mu.Lock() + b.subscribers = append(b.subscribers, ch) + b.mu.Unlock() + + safego.Go(func() { + for { + select { + case <-b.ctx.Done(): + return + case ev, ok := <-ch: + if !ok { + return + } + sub.HandleBlocked(b.ctx, ev) + } + } + }) +} + +func (b *Broadcaster) Publish(event BlockedEvent) { + if b.closed.Load() { + return + } + + b.mu.RLock() + defer b.mu.RUnlock() + + for _, ch := range b.subscribers { + select { + case ch <- event: + default: + log.Warnf("[events] blocked-event queue full; dropping hostname %s", event.Hostname) + } + } +} + +func (b *Broadcaster) Close() { + if b.closed.Load() { + return + } + + b.cancel() + + b.mu.Lock() + defer b.mu.Unlock() + subs := b.subscribers + b.subscribers = nil + + for _, ch := range subs { + close(ch) + } + b.closed.Store(true) +} diff --git a/components/egress/pkg/events/events_test.go b/components/egress/pkg/events/events_test.go new file mode 100644 index 0000000..6f93f12 --- /dev/null +++ b/components/egress/pkg/events/events_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package events + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/stretchr/testify/require" +) + +type captureSubscriber struct { + recv chan BlockedEvent +} + +func (c *captureSubscriber) HandleBlocked(_ context.Context, ev BlockedEvent) { + c.recv <- ev +} + +type blockingSubscriber struct { + block chan struct{} +} + +func (b *blockingSubscriber) HandleBlocked(_ context.Context, ev BlockedEvent) { + // Block until the channel is closed to simulate a slow consumer and trigger backpressure. + <-b.block + _ = ev +} + +func TestBroadcasterFanout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + b := NewBroadcaster(ctx, BroadcasterConfig{QueueSize: 2}) + + sub1 := &captureSubscriber{recv: make(chan BlockedEvent, 1)} + sub2 := &captureSubscriber{recv: make(chan BlockedEvent, 1)} + b.AddSubscriber(sub1) + b.AddSubscriber(sub2) + + ev := BlockedEvent{Hostname: "example.com.", Timestamp: time.Now()} + b.Publish(ev) + + select { + case got := <-sub1.recv: + require.Equal(t, ev.Hostname, got.Hostname, "sub1 expected hostname") + case <-time.After(2 * time.Second): + require.FailNow(t, "sub1 did not receive event") + } + + select { + case got := <-sub2.recv: + require.Equal(t, ev.Hostname, got.Hostname, "sub2 expected hostname") + case <-time.After(2 * time.Second): + require.FailNow(t, "sub2 did not receive event") + } + + b.Close() +} + +func TestBroadcasterDropsWhenSubscriberBackedUp(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Small queue; blocking subscriber will hold the first event. + b := NewBroadcaster(ctx, BroadcasterConfig{QueueSize: 1}) + block := make(chan struct{}) + sub := &blockingSubscriber{block: block} + b.AddSubscriber(sub) + + ev1 := BlockedEvent{Hostname: "first.example", Timestamp: time.Now()} + ev2 := BlockedEvent{Hostname: "second.example", Timestamp: time.Now()} + + b.Publish(ev1) + // This publish should drop because subscriber is blocked and queue size is 1. + b.Publish(ev2) + + // Allow subscriber to drain and exit. + close(block) + + b.Close() +} + +func TestWebhookSubscriberSendsPayload(t *testing.T) { + var ( + gotMethod string + gotPayload webhookPayload + ) + const ( + sandboxIDInitial = "sandbox-test" + sandboxIDLater = "sandbox-updated" + ) + t.Setenv(constants.EnvSandboxID, sandboxIDInitial) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + _ = json.Unmarshal(body, &gotPayload) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + sub := NewWebhookSubscriber(server.URL) + require.NotNil(t, sub, "webhook subscriber should not be nil") + t.Setenv(constants.EnvSandboxID, sandboxIDLater) + + ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + ev := BlockedEvent{Hostname: "Example.com.", Timestamp: ts} + sub.HandleBlocked(context.Background(), ev) + + require.Equal(t, http.MethodPost, gotMethod, "expected POST") + require.Equal(t, ev.Hostname, gotPayload.Hostname, "expected hostname") + require.Equal(t, webhookSource, gotPayload.Source, "expected source") + require.Equal(t, sandboxIDInitial, gotPayload.SandboxID, "expected sandboxId captured at init") + require.NotEmpty(t, gotPayload.Timestamp, "expected timestamp to be set") +} diff --git a/components/egress/pkg/events/webhook.go b/components/egress/pkg/events/webhook.go new file mode 100644 index 0000000..7cd4518 --- /dev/null +++ b/components/egress/pkg/events/webhook.go @@ -0,0 +1,124 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package events + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +const ( + webhookSource = "opensandbox-egress" + defaultWebhookTimeout = 5 * time.Second + defaultWebhookRetries = 3 + defaultWebhookBackoff = 1 * time.Second +) + +type WebhookSubscriber struct { + url string + client *http.Client + timeout time.Duration + maxRetries int + backoff time.Duration + sandboxID string +} + +type webhookPayload struct { + Hostname string `json:"hostname"` + Timestamp string `json:"timestamp"` + Source string `json:"source"` + SandboxID string `json:"sandboxId"` +} + +// NewWebhookSubscriber posts JSON to url with small retry/backoff (default queue consumer). +func NewWebhookSubscriber(url string) *WebhookSubscriber { + if url == "" { + return nil + } + return &WebhookSubscriber{ + url: url, + client: &http.Client{}, + timeout: defaultWebhookTimeout, + maxRetries: defaultWebhookRetries, + backoff: defaultWebhookBackoff, + sandboxID: os.Getenv(constants.EnvSandboxID), + } +} + +func (w *WebhookSubscriber) HandleBlocked(ctx context.Context, ev BlockedEvent) { + payload := webhookPayload{ + Hostname: ev.Hostname, + Timestamp: ev.Timestamp.UTC().Format(time.RFC3339), + Source: webhookSource, + SandboxID: w.sandboxID, + } + body, err := json.Marshal(payload) + if err != nil { + log.Warnf("[webhook] failed to marshal payload for hostname %s: %v", ev.Hostname, err) + return + } + + var lastErr error + for attempt := 0; attempt <= w.maxRetries; attempt++ { + reqCtx := ctx + cancel := func() {} + if w.timeout > 0 { + reqCtx, cancel = context.WithTimeout(ctx, w.timeout) + } + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, w.url, bytes.NewReader(body)) + if err != nil { + cancel() + lastErr = err + break + } + req.Header.Set("Content-Type", "application/json") + + resp, err := w.client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode < 300 { + cancel() + return + } + if resp.StatusCode < 500 { + cancel() + log.Warnf("[webhook] non-retriable status %d for hostname %s", resp.StatusCode, payload.Hostname) + return + } + err = fmt.Errorf("status %d", resp.StatusCode) + } + + cancel() + lastErr = err + if attempt < w.maxRetries { + time.Sleep(w.backoff * time.Duration(1<= 0; i-- { + args := append([]string(nil), applied[i]...) + args[3] = "-D" + if output, err := r.runCommand(ctx, args); err != nil { + if isIptablesMissingRuleError(output, err) { + continue + } + errs = append(errs, fmt.Sprintf("%v (output: %s)", err, strings.TrimSpace(string(output)))) + } + } + if len(errs) > 0 { + return fmt.Errorf("%s", strings.Join(errs, "; ")) + } + return nil +} + +func (r redirectRunner) removeNftRedirectTables(ctx context.Context) error { + var errs []error + for _, family := range dnsRedirectNftFamilies { + script := fmt.Sprintf("delete table %s %s\n", family, dnsRedirectNftTable) + if output, err := r.runNft(ctx, script); err != nil && !isNftMissingTableError(output, err) { + errs = append(errs, fmt.Errorf("%w (output: %s)", err, strings.TrimSpace(string(output)))) + } + } + if len(errs) > 0 { + return nftCleanupError{errs: errs} + } + return nil +} + +func isIptablesMissingRuleError(output []byte, err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error() + " " + string(output)) + return strings.Contains(msg, "bad rule") && + strings.Contains(msg, "matching rule") +} + +func isNftUnavailableError(err error) bool { + var cleanupErr nftCleanupError + if errors.As(err, &cleanupErr) { + for _, childErr := range cleanupErr.errs { + if !isNftUnavailableError(childErr) { + return false + } + } + return len(cleanupErr.errs) > 0 + } + if errors.Is(err, exec.ErrNotFound) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "protocol not supported") || + strings.Contains(msg, "operation not supported") || + strings.Contains(msg, "nf_tables") && strings.Contains(msg, "not supported") +} + +func isNftMissingTableError(output []byte, err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error() + " " + string(output)) + return strings.Contains(msg, "no such file or directory") && + strings.Contains(msg, "delete table ") && + strings.Contains(msg, " "+dnsRedirectNftTable) +} + +func removeNftDeleteTableLine(script string) string { + var lines []string + for _, line := range strings.Split(script, "\n") { + if strings.HasPrefix(line, "delete table ") && strings.HasSuffix(line, " "+dnsRedirectNftTable) { + continue + } + if strings.TrimSpace(line) == "" { + continue + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") + "\n" +} + +func isIptablesNftOutputAppendMissingChain(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "nf_tables") && + strings.Contains(msg, "rule_append failed") && + strings.Contains(msg, "no such file or directory") && + strings.Contains(msg, "chain output") +} + +func dnsRedirectNftScript(port int, exemptDst []netip.Addr) string { + var b strings.Builder + fmt.Fprintf(&b, "delete table ip %s\n", dnsRedirectNftTable) + fmt.Fprintf(&b, "delete table ip6 %s\n", dnsRedirectNftTable) + fmt.Fprintf(&b, "add table ip %s\n", dnsRedirectNftTable) + fmt.Fprintf(&b, "add chain ip %s output { type nat hook output priority -100; policy accept; }\n", dnsRedirectNftTable) + fmt.Fprintf(&b, "add table ip6 %s\n", dnsRedirectNftTable) + fmt.Fprintf(&b, "add chain ip6 %s output { type nat hook output priority -100; policy accept; }\n", dnsRedirectNftTable) + for _, addr := range exemptDst { + if addr.Is4() { + fmt.Fprintf(&b, "add rule ip %s output udp dport 53 ip daddr %s return\n", dnsRedirectNftTable, addr.String()) + fmt.Fprintf(&b, "add rule ip %s output tcp dport 53 ip daddr %s return\n", dnsRedirectNftTable, addr.String()) + } else { + fmt.Fprintf(&b, "add rule ip6 %s output udp dport 53 ip6 daddr %s return\n", dnsRedirectNftTable, addr.String()) + fmt.Fprintf(&b, "add rule ip6 %s output tcp dport 53 ip6 daddr %s return\n", dnsRedirectNftTable, addr.String()) + } + } + fmt.Fprintf(&b, "add rule ip %s output meta mark %s udp dport 53 return\n", dnsRedirectNftTable, constants.MarkHex) + fmt.Fprintf(&b, "add rule ip %s output meta mark %s tcp dport 53 return\n", dnsRedirectNftTable, constants.MarkHex) + fmt.Fprintf(&b, "add rule ip %s output udp dport 53 redirect to :%d\n", dnsRedirectNftTable, port) + fmt.Fprintf(&b, "add rule ip %s output tcp dport 53 redirect to :%d\n", dnsRedirectNftTable, port) + fmt.Fprintf(&b, "add rule ip6 %s output meta mark %s udp dport 53 return\n", dnsRedirectNftTable, constants.MarkHex) + fmt.Fprintf(&b, "add rule ip6 %s output meta mark %s tcp dport 53 return\n", dnsRedirectNftTable, constants.MarkHex) + fmt.Fprintf(&b, "add rule ip6 %s output udp dport 53 redirect to :%d\n", dnsRedirectNftTable, port) + fmt.Fprintf(&b, "add rule ip6 %s output tcp dport 53 redirect to :%d\n", dnsRedirectNftTable, port) + return b.String() +} + +// SetupRedirect: OUTPUT 53 (udp/tcp) → port; sk_mark RETURN (proxy) and per-dst RETURN (exempt list) first. +func SetupRedirect(port int, exemptDst []netip.Addr) error { + log.Infof("installing iptables DNS redirect: OUTPUT port 53 -> %d (mark %s bypass)", port, constants.MarkHex) + if err := defaultRedirectRunner().setupRedirect(context.Background(), port, exemptDst); err != nil { + return err + } + log.Infof("DNS redirect installed successfully") + return nil +} + +// RemoveRedirect deletes the same rules as SetupRedirect in reverse order; ignores missing rules. +func RemoveRedirect(port int, exemptDst []netip.Addr) { + if err := defaultRedirectRunner().removeNftRedirectTables(context.Background()); err != nil { + log.Warnf("nft DNS redirect remove table (ignored): %v", err) + } + rules := dnsRedirectRules(port, exemptDst, "-D") + for i := len(rules) - 1; i >= 0; i-- { + args := rules[i] + if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + log.Warnf("iptables remove rule (ignored): %v (output: %s)", err, strings.TrimSpace(string(output))) + } + } + log.Infof("iptables DNS redirect removed") +} diff --git a/components/egress/pkg/iptables/redirect_test.go b/components/egress/pkg/iptables/redirect_test.go new file mode 100644 index 0000000..91dbe57 --- /dev/null +++ b/components/egress/pkg/iptables/redirect_test.go @@ -0,0 +1,322 @@ +package iptables + +import ( + "context" + "errors" + "net/netip" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetupRedirectFallsBackToNftWhenIptablesNftOutputAppendFails(t *testing.T) { + var iptablesCalls [][]string + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, args []string) ([]byte, error) { + iptablesCalls = append(iptablesCalls, append([]string(nil), args...)) + return []byte("iptables v1.8.9 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain OUTPUT\n"), errors.New("exit status 4") + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, []netip.Addr{ + netip.MustParseAddr("10.179.156.2"), + netip.MustParseAddr("fd00::53"), + }) + + require.NoError(t, err) + require.NotEmpty(t, iptablesCalls) + setupScript := nftScripts[len(nftScripts)-1] + require.Contains(t, setupScript, "add table ip opensandbox_dns_redirect") + require.Contains(t, setupScript, "add table ip6 opensandbox_dns_redirect") + require.NotContains(t, setupScript, "add table inet opensandbox_dns_redirect") + require.Contains(t, setupScript, "type nat hook output priority -100; policy accept;") + require.Contains(t, setupScript, "udp dport 53 ip daddr 10.179.156.2 return") + require.Contains(t, setupScript, "tcp dport 53 ip6 daddr fd00::53 return") + require.Contains(t, setupScript, "udp dport 53 redirect to :15353") + require.Contains(t, setupScript, "tcp dport 53 redirect to :15353") + require.False(t, strings.Contains(setupScript, "hook prerouting")) +} + +func TestSetupRedirectClearsStaleNftFallbackWhenIptablesSucceeds(t *testing.T) { + var events []string + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + events = append(events, "iptables") + return nil, nil + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + events = append(events, "nft") + nftScripts = append(nftScripts, script) + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.Contains(t, nftScripts, "delete table inet opensandbox_dns_redirect\n") + require.Contains(t, nftScripts, "delete table ip opensandbox_dns_redirect\n") + require.Contains(t, nftScripts, "delete table ip6 opensandbox_dns_redirect\n") + require.GreaterOrEqual(t, len(events), 4) + require.Equal(t, []string{"nft", "nft", "nft", "iptables"}, events[:4]) +} + +func TestSetupRedirectIgnoresMissingStaleNftFallbackWhenIptablesSucceeds(t *testing.T) { + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + return nil, nil + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + return []byte("Error: Could not process rule: No such file or directory\n" + script), errors.New("exit status 1") + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) +} + +func TestSetupRedirectIgnoresUnavailableNftCleanupWhenIptablesSucceeds(t *testing.T) { + var iptablesCalls int + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + iptablesCalls++ + return nil, nil + }, + runNft: func(_ context.Context, _ string) ([]byte, error) { + return nil, exec.ErrNotFound + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.Equal(t, 8, iptablesCalls) +} + +func TestSetupRedirectIgnoresUnsupportedNftCleanupWhenIptablesSucceeds(t *testing.T) { + var iptablesCalls int + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + iptablesCalls++ + return nil, nil + }, + runNft: func(_ context.Context, _ string) ([]byte, error) { + return []byte("Error: Could not process rule: Protocol not supported"), errors.New("exit status 1") + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.Equal(t, 8, iptablesCalls) +} + +func TestSetupRedirectContinuesNftCleanupAfterUnsupportedFamilyWhenIptablesSucceeds(t *testing.T) { + var iptablesCalls int + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + iptablesCalls++ + return nil, nil + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + if strings.Contains(script, "delete table inet ") { + return []byte("Error: Could not process rule: Protocol not supported"), errors.New("exit status 1") + } + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.Equal(t, 8, iptablesCalls) + require.Contains(t, nftScripts, "delete table inet opensandbox_dns_redirect\n") + require.Contains(t, nftScripts, "delete table ip opensandbox_dns_redirect\n") + require.Contains(t, nftScripts, "delete table ip6 opensandbox_dns_redirect\n") +} + +func TestSetupRedirectReturnsNftFallbackErrorWhenUnsupportedNftIsRequired(t *testing.T) { + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + return []byte("iptables v1.8.9 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain OUTPUT\n"), errors.New("exit status 4") + }, + runNft: func(_ context.Context, _ string) ([]byte, error) { + return []byte("Error: Could not process rule: Protocol not supported"), errors.New("exit status 1") + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "nft DNS redirect fallback failed") + require.Contains(t, err.Error(), "Protocol not supported") +} + +func TestSetupRedirectReturnsNonMissingStaleNftFallbackCleanupError(t *testing.T) { + var iptablesCalls int + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + iptablesCalls++ + return nil, nil + }, + runNft: func(_ context.Context, _ string) ([]byte, error) { + return []byte("Operation not permitted"), errors.New("exit status 1") + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "nft DNS redirect cleanup failed") + require.Zero(t, iptablesCalls) +} + +func TestSetupRedirectRollsBackPartialIptablesRulesBeforeNftFallback(t *testing.T) { + var iptablesCalls [][]string + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, args []string) ([]byte, error) { + iptablesCalls = append(iptablesCalls, append([]string(nil), args...)) + if len(iptablesCalls) == 5 { + return []byte("iptables v1.8.9 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain OUTPUT\n"), errors.New("exit status 4") + } + return nil, nil + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.GreaterOrEqual(t, len(iptablesCalls), 9) + require.Equal(t, "-A", iptablesCalls[0][3]) + require.Equal(t, "-A", iptablesCalls[3][3]) + require.Equal(t, "-D", iptablesCalls[5][3]) + require.Equal(t, "-D", iptablesCalls[8][3]) + require.Equal(t, iptablesCalls[3][4:], iptablesCalls[5][4:]) + require.Equal(t, iptablesCalls[0][4:], iptablesCalls[8][4:]) + require.Contains(t, nftScripts[len(nftScripts)-1], "add table ip opensandbox_dns_redirect") + require.Contains(t, nftScripts[len(nftScripts)-1], "add table ip6 opensandbox_dns_redirect") +} + +func TestSetupRedirectReturnsRollbackErrorBeforeNftFallback(t *testing.T) { + var iptablesCalls [][]string + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, args []string) ([]byte, error) { + iptablesCalls = append(iptablesCalls, append([]string(nil), args...)) + switch { + case len(iptablesCalls) == 3: + return []byte("iptables v1.8.9 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain OUTPUT\n"), errors.New("exit status 4") + case args[3] == "-D": + return []byte("delete failed"), errors.New("exit status 1") + default: + return nil, nil + } + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "iptables rollback failed") + for _, script := range nftScripts { + require.NotContains(t, script, "add table inet opensandbox_dns_redirect") + } +} + +func TestSetupRedirectIgnoresAlreadyAbsentRollbackRuleBeforeNftFallback(t *testing.T) { + var iptablesCalls [][]string + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, args []string) ([]byte, error) { + iptablesCalls = append(iptablesCalls, append([]string(nil), args...)) + switch { + case len(iptablesCalls) == 3: + return []byte("iptables v1.8.9 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain OUTPUT\n"), errors.New("exit status 4") + case args[3] == "-D": + return []byte("iptables: Bad rule (does a matching rule exist in that chain?)."), errors.New("exit status 1") + default: + return nil, nil + } + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.Contains(t, nftScripts[len(nftScripts)-1], "add table ip opensandbox_dns_redirect") + require.Contains(t, nftScripts[len(nftScripts)-1], "add table ip6 opensandbox_dns_redirect") +} + +func TestSetupRedirectRetriesNftFallbackWithoutDeleteWhenTableIsMissing(t *testing.T) { + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + return []byte("iptables v1.8.9 (nf_tables): RULE_APPEND failed (No such file or directory): rule in chain OUTPUT\n"), errors.New("exit status 4") + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + if strings.Contains(script, "add table ip opensandbox_dns_redirect") && strings.Contains(script, "delete table ip opensandbox_dns_redirect") { + return []byte("Error: Could not process rule: No such file or directory\ndelete table ip opensandbox_dns_redirect"), errors.New("exit status 1") + } + return nil, nil + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.NoError(t, err) + require.Len(t, nftScripts, 5) + failedSetupScript := nftScripts[len(nftScripts)-2] + retryScript := nftScripts[len(nftScripts)-1] + require.Contains(t, failedSetupScript, "delete table ip opensandbox_dns_redirect") + require.NotContains(t, retryScript, "delete table ip opensandbox_dns_redirect") + require.NotContains(t, retryScript, "delete table ip6 opensandbox_dns_redirect") + require.Contains(t, retryScript, "add table ip opensandbox_dns_redirect") + require.Contains(t, retryScript, "add table ip6 opensandbox_dns_redirect") +} + +func TestSetupRedirectReturnsOriginalIptablesErrorWhenFallbackIsNotApplicable(t *testing.T) { + var nftScripts []string + r := redirectRunner{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + return []byte("permission denied"), errors.New("exit status 4") + }, + runNft: func(_ context.Context, script string) ([]byte, error) { + nftScripts = append(nftScripts, script) + return []byte("Error: Could not process rule: No such file or directory\n" + script), errors.New("exit status 1") + }, + } + + err := r.setupRedirect(context.Background(), 15353, nil) + + require.Error(t, err) + require.Contains(t, err.Error(), "permission denied") + for _, script := range nftScripts { + require.NotContains(t, script, "add table ip opensandbox_dns_redirect") + require.NotContains(t, script, "add table ip6 opensandbox_dns_redirect") + } +} diff --git a/components/egress/pkg/iptables/transparent.go b/components/egress/pkg/iptables/transparent.go new file mode 100644 index 0000000..b48a0c3 --- /dev/null +++ b/components/egress/pkg/iptables/transparent.go @@ -0,0 +1,82 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package iptables + +import ( + "fmt" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +func transparentHTTPRules(localPort int, mitmUID uint32, op string) [][]string { + target := strconv.Itoa(localPort) + uid := strconv.FormatUint(uint64(mitmUID), 10) + loopRules := [][]string{ + {"iptables", "-t", "nat", op, "OUTPUT", "-p", "tcp", "-d", "127.0.0.0/8", "-j", "RETURN"}, + } + redir := [][]string{ + { + "iptables", "-t", "nat", op, "OUTPUT", "-p", "tcp", + "-m", "owner", "!", "--uid-owner", uid, + "-m", "multiport", "--dports", "80,443", + "-j", "REDIRECT", "--to-ports", target, + }, + } + return append(loopRules, redir...) +} + +// SetupTransparentHTTP: non-mitm UIDs get OUTPUT tcp:80,443 → localPort; loopback and mitm’s traffic excluded. +func SetupTransparentHTTP(localPort int, mitmUID uint32) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("iptables transparent: only supported on linux") + } + + if localPort <= 0 { + return fmt.Errorf("iptables transparent: invalid port or uid") + } + target := strconv.Itoa(localPort) + uid := strconv.FormatUint(uint64(mitmUID), 10) + log.Infof("installing iptables transparent: OUTPUT tcp dport 80,443 -> 127.0.0.1:%s (skip uid %s)", target, uid) + + rules := transparentHTTPRules(localPort, mitmUID, "-A") + for _, args := range rules { + if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + return fmt.Errorf("iptables transparent: %v (output: %s)", err, output) + } + } + log.Infof("iptables transparent rules installed successfully") + return nil +} + +func RemoveTransparentHTTP(localPort int, mitmUID uint32) { + if runtime.GOOS != "linux" { + return + } + if localPort <= 0 { + return + } + rules := transparentHTTPRules(localPort, mitmUID, "-D") + for i := len(rules) - 1; i >= 0; i-- { + args := rules[i] + if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { + log.Warnf("iptables transparent remove rule (ignored): %v (output: %s)", err, strings.TrimSpace(string(output))) + } + } + log.Infof("iptables transparent rules removed") +} diff --git a/components/egress/pkg/log/logger.go b/components/egress/pkg/log/logger.go new file mode 100644 index 0000000..5098875 --- /dev/null +++ b/components/egress/pkg/log/logger.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "context" + "os" + + slogger "github.com/alibaba/opensandbox/internal/logger" +) + +// Logger is the package-global sink used by egress; WithLogger is called from main after OTel/fields setup. +var Logger slogger.Logger = slogger.MustNew(slogger.Config{Level: "info"}).Named("opensandbox.egress") + +// WithLogger installs the process-wide logger for the egress binary. +func WithLogger(ctx context.Context, logger slogger.Logger) context.Context { + if logger != nil { + Logger = logger + } + return ctx +} + +func Debugf(template string, args ...any) { + Logger.Debugf(template, args...) +} + +func Infof(template string, args ...any) { + Logger.Infof(template, args...) +} + +func Warnf(template string, args ...any) { + Logger.Warnf(template, args...) +} + +func Errorf(template string, args ...any) { + Logger.Errorf(template, args...) +} + +func Fatalf(template string, args ...any) { + Logger.Errorf(template, args...) + _ = Logger.Sync() + os.Exit(1) +} diff --git a/components/egress/pkg/mitmproxy/cacert_export.go b/components/egress/pkg/mitmproxy/cacert_export.go new file mode 100644 index 0000000..258767a --- /dev/null +++ b/components/egress/pkg/mitmproxy/cacert_export.go @@ -0,0 +1,131 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +const ( + mitmCACertName = "mitmproxy-ca-cert.pem" + pollInterval = 200 * time.Millisecond + waitCACert = 20 * time.Second +) + +// candidateCACertPaths: mitm may place mitmproxy-ca-cert.pem in confdir, .mitmproxy under confdir, or home. +func candidateCACertPaths(confDirEnv, home string) []string { + confDirEnv = strings.TrimSpace(confDirEnv) + var out []string + if confDirEnv != "" { + out = append(out, + filepath.Join(confDirEnv, mitmCACertName), + filepath.Join(confDirEnv, ".mitmproxy", mitmCACertName), + ) + } + out = append(out, filepath.Join(home, ".mitmproxy", mitmCACertName)) + return out +} + +func waitMitmCACertPath(confDirEnv, home string) (string, error) { + cands := candidateCACertPaths(confDirEnv, home) + deadline := time.Now().Add(waitCACert) + for time.Now().Before(deadline) { + for _, p := range cands { + st, err := os.Stat(p) + if err == nil && !st.IsDir() && st.Size() > 0 { + return p, nil + } + } + time.Sleep(pollInterval) + } + return "", fmt.Errorf("mitmproxy CA not found after %v (tried: %v)", waitCACert, cands) +} + +func SyncRootCA(confDirEnv, home string) error { + src, err := waitMitmCACertPath(confDirEnv, home) + if err != nil { + return err + } + if err := os.MkdirAll(constants.OpenSandboxRootDir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", constants.OpenSandboxRootDir, err) + } + dst := filepath.Join(constants.OpenSandboxRootDir, mitmCACertName) + if err := copyFile(src, dst, 0o644); err != nil { + return fmt.Errorf("copy mitm CA to %s: %w", dst, err) + } + log.Infof("[mitmproxy] copied root CA to %s", dst) + + if err := installMitmCAInSystemTrust(dst); err != nil { + return fmt.Errorf("install mitm CA into system trust store: %w", err) + } + return nil +} + +func installMitmCAInSystemTrust(pemPath string) error { + if _, err := exec.LookPath("update-ca-certificates"); err != nil { + return fmt.Errorf("update-ca-certificates not found (install ca-certificates in the egress image): %w", err) + } + dir := "/usr/local/share/ca-certificates" + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + systemDst := filepath.Join(dir, "opensandbox-mitmproxy-ca.crt") + if err := copyFile(pemPath, systemDst, 0o644); err != nil { + return fmt.Errorf("copy CA to %s: %w", systemDst, err) + } + out, err := exec.Command("update-ca-certificates").CombinedOutput() + if err != nil { + return fmt.Errorf("update-ca-certificates: %w: %s", err, strings.TrimSpace(string(out))) + } + log.Infof("[mitmproxy] egress container: mitm CA added to system trust (update-ca-certificates)") + return nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + tmp, err := os.CreateTemp(filepath.Dir(dst), "."+mitmCACertName+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := io.Copy(tmp, in); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(mode); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, dst) +} diff --git a/components/egress/pkg/mitmproxy/cacert_export_test.go b/components/egress/pkg/mitmproxy/cacert_export_test.go new file mode 100644 index 0000000..13731c7 --- /dev/null +++ b/components/egress/pkg/mitmproxy/cacert_export_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCandidateCACertPaths(t *testing.T) { + h := "/var/lib/mitmproxy" + got := candidateCACertPaths("", h) + require.Equal(t, []string{filepath.Join(h, ".mitmproxy", mitmCACertName)}, got) + + got = candidateCACertPaths("/custom", h) + require.Equal(t, []string{ + filepath.Join("/custom", mitmCACertName), + filepath.Join("/custom", ".mitmproxy", mitmCACertName), + filepath.Join(h, ".mitmproxy", mitmCACertName), + }, got) +} diff --git a/components/egress/pkg/mitmproxy/health_gate.go b/components/egress/pkg/mitmproxy/health_gate.go new file mode 100644 index 0000000..3709c2b --- /dev/null +++ b/components/egress/pkg/mitmproxy/health_gate.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "context" + "os" + "sync/atomic" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" +) + +// HealthGate: /healthz stays 503 until MarkStackReady when transparent mitm is required (env enabled). +type HealthGate struct { + required bool + ready atomic.Bool +} + +func NewHealthGate() *HealthGate { + required := constants.IsTruthy(os.Getenv(constants.EnvMitmproxyTransparent)) + g := &HealthGate{required: required} + if !required { + g.ready.Store(true) + } + return g +} + +func (g *HealthGate) MarkStackReady() { + g.SetReady(true) +} + +func (g *HealthGate) SetReady(v bool) { + if g != nil { + g.ready.Store(v) + } +} + +func (g *HealthGate) MitmPending() bool { + if g == nil { + return false + } + return g.required && !g.ready.Load() +} + +// WaitReady polls until the gate is ready, ctx is cancelled, or 30s elapses. +func (g *HealthGate) WaitReady(ctx context.Context) bool { + deadline := time.After(30 * time.Second) + for g.MitmPending() { + select { + case <-ctx.Done(): + return false + case <-deadline: + return false + case <-time.After(100 * time.Millisecond): + } + } + return true +} diff --git a/components/egress/pkg/mitmproxy/health_gate_test.go b/components/egress/pkg/mitmproxy/health_gate_test.go new file mode 100644 index 0000000..c02d78a --- /dev/null +++ b/components/egress/pkg/mitmproxy/health_gate_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "testing" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/stretchr/testify/require" +) + +func TestHealthGate(t *testing.T) { + t.Run("transparent", func(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "1") + on := NewHealthGate() + require.True(t, on.MitmPending()) + on.MarkStackReady() + require.False(t, on.MitmPending()) + }) + t.Run("not transparent", func(t *testing.T) { + t.Setenv(constants.EnvMitmproxyTransparent, "") + off := NewHealthGate() + require.False(t, off.MitmPending()) + }) +} diff --git a/components/egress/pkg/mitmproxy/launch.go b/components/egress/pkg/mitmproxy/launch.go new file mode 100644 index 0000000..027af79 --- /dev/null +++ b/components/egress/pkg/mitmproxy/launch.go @@ -0,0 +1,159 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "runtime" + "strconv" + "strings" + "syscall" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/internal/safego" +) + +const RunAsUser = "mitmproxy" + +// Loopback: transparent mode receives via REDIRECT; do not listen on 0.0.0.0 in the netns. +// Kept as a Go constant only for the startup log line; the actual listen_host is set in +// /var/lib/mitmproxy/.mitmproxy/config.yaml (shipped via the egress Dockerfile). +const listenHostLoopback = "127.0.0.1" + +// systemScriptPath: bundled system addon shipped via the egress Dockerfile +// (COPY components/egress/mitmscripts /var/egress/mitmscripts). Always loaded. +const systemScriptPath = "/var/egress/mitmscripts/system.py" + +// Config: mitmdump --mode transparent. Static options (mode, connection_strategy, +// listen_host, stream_large_bodies, ignore_hosts, +// ssl_verify_upstream_trusted_confdir) live in +// /var/lib/mitmproxy/.mitmproxy/config.yaml and are auto-loaded by mitmdump. +// This struct carries only per-launch dynamic values that override those +// defaults via `--set`. +type Config struct { + ListenPort int + UserName string + // ScriptPaths are optional user-supplied addons, loaded after the system addon + // in the order given. Parsed from the comma-separated OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT env var. + ScriptPaths []string + // OnExit is called (if non-nil) when mitmdump exits. Called from a background goroutine. + OnExit func(error) +} + +// Running: child mitmdump; use GracefulShutdown to SIGTERM+reap before process exit. +type Running struct { + Cmd *exec.Cmd + done chan error +} + +func LookupUser(userName string) (uid, gid uint32, home string, err error) { + if strings.TrimSpace(userName) == "" { + userName = RunAsUser + } + u, err := user.Lookup(userName) + if err != nil { + return 0, 0, "", err + } + uid64, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return 0, 0, "", err + } + gid64, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return 0, 0, "", err + } + return uint32(uid64), uint32(gid64), u.HomeDir, nil +} + +// Launch starts mitmdump in the background; check Wait/GracefulShutdown on the returned Running. +func Launch(cfg Config) (*Running, error) { + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("mitmproxy: transparent mitmdump is only supported on linux") + } + + if cfg.ListenPort <= 0 { + return nil, fmt.Errorf("mitmproxy: invalid listen port") + } + uname := cfg.UserName + if strings.TrimSpace(uname) == "" { + uname = RunAsUser + } + uid, gid, home, err := LookupUser(uname) + if err != nil { + return nil, fmt.Errorf("mitmproxy: lookup user %q: %w", uname, err) + } + + args := buildMitmdumpArgs(cfg) + + cmd := exec.Command("mitmdump", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{Uid: uid, Gid: gid}, + } + // HOME determines mitm's confdir (~/.mitmproxy) which holds both the CA + // and the baked-in config.yaml. + cmd.Env = buildMitmdumpEnv(os.Environ(), home) + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("mitmproxy: start mitmdump: %w", err) + } + done := make(chan error, 1) + onExit := cfg.OnExit + safego.Go(func() { + err := cmd.Wait() + done <- err + if onExit != nil { + onExit(err) + } + }) + + log.Infof("[mitmproxy] mitmdump started (pid %d, transparent on %s:%d)", cmd.Process.Pid, listenHostLoopback, cfg.ListenPort) + return &Running{Cmd: cmd, done: done}, nil +} + +func buildMitmdumpArgs(cfg Config) []string { + args := []string{ + "--listen-port", strconv.Itoa(cfg.ListenPort), + "--set", "flow_detail=0", + } + + if trustDir := strings.TrimSpace(os.Getenv(constants.EnvMitmproxyUpstreamTrustDir)); trustDir != "" { + args = append(args, "--set", "ssl_verify_upstream_trusted_confdir="+trustDir) + } + + if constants.IsTruthy(os.Getenv(constants.EnvMitmproxySslInsecure)) { + args = append(args, "--set", "ssl_insecure=true") + } + + args = append(args, "-s", systemScriptPath) + for _, p := range cfg.ScriptPaths { + if s := strings.TrimSpace(p); s != "" { + args = append(args, "-s", s) + } + } + return args +} + +func buildMitmdumpEnv(base []string, home string) []string { + env := make([]string, 0, len(base)+1) + env = append(env, base...) + env = append(env, "HOME="+home) + return env +} diff --git a/components/egress/pkg/mitmproxy/launch_test.go b/components/egress/pkg/mitmproxy/launch_test.go new file mode 100644 index 0000000..307b162 --- /dev/null +++ b/components/egress/pkg/mitmproxy/launch_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildMitmdumpArgsNoUserScripts(t *testing.T) { + args := buildMitmdumpArgs(Config{ListenPort: 18081}) + require.Contains(t, args, "--listen-port") + require.Contains(t, args, "18081") + require.Contains(t, args, "--set") + require.Contains(t, args, "flow_detail=0") + require.Contains(t, args, "-s") + require.Contains(t, args, systemScriptPath) + // Only one -s (system addon) + count := 0 + for _, a := range args { + if a == "-s" { + count++ + } + } + require.Equal(t, 1, count) +} + +func TestBuildMitmdumpArgsSingleUserScript(t *testing.T) { + args := buildMitmdumpArgs(Config{ + ListenPort: 18081, + ScriptPaths: []string{"/scripts/auth.py"}, + }) + count := 0 + for _, a := range args { + if a == "-s" { + count++ + } + } + require.Equal(t, 2, count) + require.Equal(t, "/scripts/auth.py", args[len(args)-1]) +} + +func TestBuildMitmdumpArgsMultipleUserScripts(t *testing.T) { + args := buildMitmdumpArgs(Config{ + ListenPort: 18081, + ScriptPaths: []string{"/scripts/auth.py", "/scripts/logging.py"}, + }) + count := 0 + for _, a := range args { + if a == "-s" { + count++ + } + } + require.Equal(t, 3, count) + // Order: system, auth, logging + scripts := []string{} + for i, a := range args { + if a == "-s" { + scripts = append(scripts, args[i+1]) + } + } + require.Equal(t, []string{systemScriptPath, "/scripts/auth.py", "/scripts/logging.py"}, scripts) +} + +func TestBuildMitmdumpArgsSkipsEmptyScriptPaths(t *testing.T) { + args := buildMitmdumpArgs(Config{ + ListenPort: 18081, + ScriptPaths: []string{" ", "/scripts/auth.py", "", " /scripts/logging.py "}, + }) + scripts := []string{} + for i, a := range args { + if a == "-s" { + scripts = append(scripts, args[i+1]) + } + } + require.Equal(t, []string{systemScriptPath, "/scripts/auth.py", "/scripts/logging.py"}, scripts) +} + +func TestBuildMitmdumpEnvSetsMitmproxyHome(t *testing.T) { + env := buildMitmdumpEnv( + []string{ + "PATH=/usr/bin", + }, + "/home/mitmproxy", + ) + + require.Contains(t, env, "PATH=/usr/bin") + require.Contains(t, env, "HOME=/home/mitmproxy") +} diff --git a/components/egress/pkg/mitmproxy/stop.go b/components/egress/pkg/mitmproxy/stop.go new file mode 100644 index 0000000..e6f8ebb --- /dev/null +++ b/components/egress/pkg/mitmproxy/stop.go @@ -0,0 +1,82 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "fmt" + "os/exec" + "syscall" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +// GracefulShutdown sends SIGTERM to mitmdump, waits up to timeout, then SIGKILL if still running. +func GracefulShutdown(r *Running, timeout time.Duration) { + if r == nil || r.Cmd == nil { + return + } + if r.Cmd.Process == nil { + return + } + + select { + case err := <-r.done: + if err != nil { + log.Warnf("[mitmproxy] mitmdump already exited: %v", err) + } + return + default: + } + + if err := r.Cmd.Process.Signal(syscall.SIGTERM); err != nil { + log.Warnf("[mitmproxy] SIGTERM mitmdump: %v", err) + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case err := <-r.done: + if err != nil && !isSigKillOrTerm(err) { + log.Warnf("[mitmproxy] mitmdump exited: %v", err) + } else { + log.Infof("[mitmproxy] mitmdump stopped") + } + return + case <-timer.C: + } + + log.Warnf("[mitmproxy] mitmdump did not exit within %v; sending SIGKILL", timeout) + if err := r.Cmd.Process.Kill(); err != nil { + log.Warnf("[mitmproxy] kill mitmdump: %v", err) + } + if err := <-r.done; err != nil && !isSigKillOrTerm(err) { + log.Warnf("[mitmproxy] mitmdump exited after kill: %v", err) + } +} + +func isSigKillOrTerm(err error) bool { + if err == nil { + return true + } + if exitErr, ok := err.(*exec.ExitError); ok { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok { + return ws.Signaled() && (ws.Signal() == syscall.SIGKILL || ws.Signal() == syscall.SIGTERM) + } + } + + return fmt.Sprint(err) == "signal: killed" || fmt.Sprint(err) == "signal: terminated" +} diff --git a/components/egress/pkg/mitmproxy/wait.go b/components/egress/pkg/mitmproxy/wait.go new file mode 100644 index 0000000..1621249 --- /dev/null +++ b/components/egress/pkg/mitmproxy/wait.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mitmproxy + +import ( + "fmt" + "net" + "time" +) + +// WaitListenPort polls until addr accepts TCP or d elapses. +func WaitListenPort(addr string, d time.Duration) error { + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", addr, 150*time.Millisecond) + if err == nil { + _ = c.Close() + return nil + } + time.Sleep(40 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for %s", addr) +} diff --git a/components/egress/pkg/nftables/dynamic.go b/components/egress/pkg/nftables/dynamic.go new file mode 100644 index 0000000..4b343cb --- /dev/null +++ b/components/egress/pkg/nftables/dynamic.go @@ -0,0 +1,72 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nftables + +import ( + "fmt" + "net/netip" + "strings" + "time" +) + +const ( + dynAllowV4Set = "dyn_allow_v4" + dynAllowV6Set = "dyn_allow_v6" + dynSetTimeoutS = 360 + // nftTTLSlackSec is added to the DNS TTL before clamping, so allow entries + // slightly outlive the resolver cache and reduce races with short TTLs. + nftTTLSlackSec = 60 + minTTLSec = 60 + maxTTLSec = 360 // max DNS TTL (300) + nftTTLSlackSec +) + +// ResolvedIP is a single IP learned from DNS with TTL for dynamic nft set. +type ResolvedIP struct { + Addr netip.Addr + TTL time.Duration +} + +// buildAddResolvedIPsScript returns a nft script fragment that +// adds resolved IPs to dyn_allow_v4/v6 with timeout. +func buildAddResolvedIPsScript(table string, ips []ResolvedIP) string { + var v4, v6 []string + for _, r := range ips { + sec := clampTTL(r.TTL) + if r.Addr.Is4() { + v4 = append(v4, fmt.Sprintf("%s timeout %ds", r.Addr.String(), sec)) + } else if r.Addr.Is6() { + v6 = append(v6, fmt.Sprintf("%s timeout %ds", r.Addr.String(), sec)) + } + } + var b strings.Builder + if len(v4) > 0 { + fmt.Fprintf(&b, "add element inet %s %s { %s }\n", table, dynAllowV4Set, strings.Join(v4, ", ")) + } + if len(v6) > 0 { + fmt.Fprintf(&b, "add element inet %s %s { %s }\n", table, dynAllowV6Set, strings.Join(v6, ", ")) + } + return b.String() +} + +func clampTTL(d time.Duration) int { + sec := int(d.Seconds()) + nftTTLSlackSec + if sec < minTTLSec { + return minTTLSec + } + if sec > maxTTLSec { + return maxTTLSec + } + return sec +} diff --git a/components/egress/pkg/nftables/interval.go b/components/egress/pkg/nftables/interval.go new file mode 100644 index 0000000..5fb08ca --- /dev/null +++ b/components/egress/pkg/nftables/interval.go @@ -0,0 +1,112 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nftables + +import ( + "fmt" + "net/netip" +) + +// normalizeNFTIntervalSet drops entries that are redundant for nftables sets with +// `flags interval`: a host or smaller CIDR that falls strictly inside another +// listed CIDR would make `nft add element` fail with "conflicting intervals specified". +// Used on every ApplyStatic (startup and /policy updates). +func normalizeNFTIntervalSet(elems []string) ([]string, error) { + if len(elems) == 0 { + return nil, nil + } + prefs := make([]netip.Prefix, 0, len(elems)) + for _, s := range elems { + p, err := parseAsPrefix(s) + if err != nil { + return nil, err + } + prefs = append(prefs, p.Masked()) + } + prefs = uniquePrefixes(prefs) + prefs = removeStrictSubnets(prefs) + out := make([]string, 0, len(prefs)) + for _, p := range prefs { + out = append(out, formatPrefixForNFT(p)) + } + return out, nil +} + +func parseAsPrefix(s string) (netip.Prefix, error) { + if p, err := netip.ParsePrefix(s); err == nil { + return p, nil + } + if a, err := netip.ParseAddr(s); err == nil { + if a.Is4() { + return a.Prefix(32) + } + return a.Prefix(128) + } + return netip.Prefix{}, fmt.Errorf("nftables: invalid IP or CIDR %q", s) +} + +func uniquePrefixes(prefs []netip.Prefix) []netip.Prefix { + seen := make(map[string]struct{}, len(prefs)) + out := make([]netip.Prefix, 0, len(prefs)) + for _, p := range prefs { + k := p.String() + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, p) + } + return out +} + +// strictSupernet reports whether super covers all addresses of sub, with super +// strictly larger (smaller prefix length) than sub. +func strictSupernet(super, sub netip.Prefix) bool { + if super.Addr().Is4() != sub.Addr().Is4() { + return false + } + if super.Bits() >= sub.Bits() { + return false + } + return super.Contains(sub.Addr()) +} + +func removeStrictSubnets(prefs []netip.Prefix) []netip.Prefix { + out := make([]netip.Prefix, 0, len(prefs)) + for _, p := range prefs { + redundant := false + for _, q := range prefs { + if strictSupernet(q, p) { + redundant = true + break + } + } + if !redundant { + out = append(out, p) + } + } + return out +} + +func formatPrefixForNFT(p netip.Prefix) string { + p = p.Masked() + if p.Addr().Is4() && p.Bits() == 32 { + return p.Addr().String() + } + if p.Addr().Is6() && p.Bits() == 128 { + return p.Addr().String() + } + return p.String() +} diff --git a/components/egress/pkg/nftables/interval_test.go b/components/egress/pkg/nftables/interval_test.go new file mode 100644 index 0000000..2fec85b --- /dev/null +++ b/components/egress/pkg/nftables/interval_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nftables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeNFTIntervalSet_cgnatContainsNameservers(t *testing.T) { + got, err := normalizeNFTIntervalSet([]string{ + "100.64.0.0/10", + "127.0.0.1", + "100.100.2.136", + "100.100.2.138", + }) + require.NoError(t, err) + require.Equal(t, []string{"100.64.0.0/10", "127.0.0.1"}, got) +} + +func TestNormalizeNFTIntervalSet_noChange(t *testing.T) { + got, err := normalizeNFTIntervalSet([]string{"1.1.1.1", "2.2.0.0/16"}) + require.NoError(t, err) + require.Equal(t, []string{"1.1.1.1", "2.2.0.0/16"}, got) +} + +func TestNormalizeNFTIntervalSet_dedupe(t *testing.T) { + got, err := normalizeNFTIntervalSet([]string{"8.8.8.8", "8.8.8.8"}) + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8"}, got) +} + +func TestNormalizeNFTIntervalSet_ipv6(t *testing.T) { + got, err := normalizeNFTIntervalSet([]string{ + "2001:db8::/32", + "2001:db8::1", + }) + require.NoError(t, err) + require.Equal(t, []string{"2001:db8::/32"}, got) +} + +func TestNormalizeNFTIntervalSet_invalid(t *testing.T) { + _, err := normalizeNFTIntervalSet([]string{"not-an-ip"}) + require.Error(t, err) +} diff --git a/components/egress/pkg/nftables/manager.go b/components/egress/pkg/nftables/manager.go new file mode 100644 index 0000000..6ee4c1a --- /dev/null +++ b/components/egress/pkg/nftables/manager.go @@ -0,0 +1,269 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nftables + +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/alibaba/opensandbox/egress/pkg/telemetry" +) + +const ( + tableName = "opensandbox" + chainName = "egress" + allowV4Set = "allow_v4" + allowV6Set = "allow_v6" + denyV4Set = "deny_v4" + denyV6Set = "deny_v6" + dohBlockV4Set = "doh_block_v4" + dohBlockV6Set = "doh_block_v6" +) + +type runner func(ctx context.Context, script string) ([]byte, error) + +type Options struct { + BlockDoT bool + BlockDoH443 bool + DoHBlocklistV4 []string + DoHBlocklistV6 []string +} + +type Manager struct { + run runner + opts Options + mu sync.Mutex +} + +func NewManager() *Manager { + return &Manager{run: defaultRunner, opts: Options{BlockDoT: true}} +} + +func NewManagerWithRunner(r runner) *Manager { + return &Manager{run: r, opts: Options{BlockDoT: true}} +} + +func NewManagerWithRunnerAndOptions(r runner, opts Options) *Manager { + return &Manager{run: r, opts: opts} +} + +func NewManagerWithOptions(opts Options) *Manager { + return &Manager{run: defaultRunner, opts: opts} +} + +func (m *Manager) ApplyStatic(ctx context.Context, p *policy.NetworkPolicy) error { + if p == nil { + p = policy.DefaultDenyPolicy() + } + allowV4, allowV6, denyV4, denyV6 := p.StaticIPSets() + log.Infof("nftables: applying static policy: default=%s, allow_v4=%d, allow_v6=%d, deny_v4=%d, deny_v6=%d", + p.DefaultAction, len(allowV4), len(allowV6), len(denyV4), len(denyV6)) + m.mu.Lock() + defer m.mu.Unlock() + script, err := buildRuleset(p, m.opts) + if err != nil { + return err + } + if _, err := m.run(ctx, script); err != nil { + if isMissingTableError(err) { + fallback := removeDeleteTableLine(script) + if fallback != script { + if _, retryErr := m.run(ctx, fallback); retryErr == nil { + telemetry.SetNftablesRuleCount(telemetry.NftRuleCountFromPolicy(p)) + telemetry.RecordNftablesUpdate() + return nil + } + } + } + return err + } + telemetry.SetNftablesRuleCount(telemetry.NftRuleCountFromPolicy(p)) + telemetry.RecordNftablesUpdate() + log.Infof("nftables: static policy applied successfully") + return nil +} + +func (m *Manager) AddResolvedIPs(ctx context.Context, ips []ResolvedIP) error { + if len(ips) == 0 { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + script := buildAddResolvedIPsScript(tableName, ips) + if script == "" { + return nil + } + log.Debugf("nftables: adding %d resolved IP(s) to dynamic allow sets with script statement %s", len(ips), script) + _, err := m.run(ctx, script) + if err == nil { + telemetry.RecordNftablesUpdate() + } + return err +} + +// RemoveEnforcement drops inet opensandbox; missing table is not an error. +func (m *Manager) RemoveEnforcement(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + script := fmt.Sprintf("delete table inet %s\n", tableName) + _, err := m.run(ctx, script) + if err != nil { + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "no such file") || strings.Contains(msg, "does not exist") { + return nil + } + return err + } + log.Infof("nftables: removed table inet %s", tableName) + return nil +} + +func buildRuleset(p *policy.NetworkPolicy, opts Options) (string, error) { + allowV4, allowV6, denyV4, denyV6 := p.StaticIPSets() + var err error + if allowV4, err = normalizeNFTIntervalSet(allowV4); err != nil { + return "", err + } + if allowV6, err = normalizeNFTIntervalSet(allowV6); err != nil { + return "", err + } + if denyV4, err = normalizeNFTIntervalSet(denyV4); err != nil { + return "", err + } + if denyV6, err = normalizeNFTIntervalSet(denyV6); err != nil { + return "", err + } + dohBlockV4 := opts.DoHBlocklistV4 + dohBlockV6 := opts.DoHBlocklistV6 + if len(dohBlockV4) > 0 { + if dohBlockV4, err = normalizeNFTIntervalSet(dohBlockV4); err != nil { + return "", err + } + } + if len(dohBlockV6) > 0 { + if dohBlockV6, err = normalizeNFTIntervalSet(dohBlockV6); err != nil { + return "", err + } + } + + var b strings.Builder + fmt.Fprintf(&b, "delete table inet %s\n", tableName) + fmt.Fprintf(&b, "add table inet %s\n", tableName) + + fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; flags interval; }\n", tableName, allowV4Set) + fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; flags interval; }\n", tableName, denyV4Set) + fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; flags interval; }\n", tableName, allowV6Set) + fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; flags interval; }\n", tableName, denyV6Set) + fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; timeout %ds; }\n", tableName, dynAllowV4Set, dynSetTimeoutS) + fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; timeout %ds; }\n", tableName, dynAllowV6Set, dynSetTimeoutS) + + if len(dohBlockV4) > 0 { + fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; flags interval; }\n", tableName, dohBlockV4Set) + } + if len(dohBlockV6) > 0 { + fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; flags interval; }\n", tableName, dohBlockV6Set) + } + + writeElements(&b, allowV4Set, allowV4) + writeElements(&b, denyV4Set, denyV4) + writeElements(&b, allowV6Set, allowV6) + writeElements(&b, denyV6Set, denyV6) + writeElements(&b, dohBlockV4Set, dohBlockV4) + writeElements(&b, dohBlockV6Set, dohBlockV6) + + chainPolicy := "drop" + if p.DefaultAction == policy.ActionAllow { + chainPolicy = "accept" + } + fmt.Fprintf(&b, "add chain inet %s %s { type filter hook output priority 0; policy %s; }\n", tableName, chainName, chainPolicy) + fmt.Fprintf(&b, "add rule inet %s %s ct state established,related accept\n", tableName, chainName) + fmt.Fprintf(&b, "add rule inet %s %s meta mark %s accept\n", tableName, chainName, constants.MarkHex) + fmt.Fprintf(&b, "add rule inet %s %s oifname \"lo\" accept\n", tableName, chainName) + if opts.BlockDoT { + fmt.Fprintf(&b, "add rule inet %s %s tcp dport 853 drop\n", tableName, chainName) + fmt.Fprintf(&b, "add rule inet %s %s udp dport 853 drop\n", tableName, chainName) + } + if opts.BlockDoH443 { + if len(dohBlockV4) == 0 && len(dohBlockV6) == 0 { + // strict: drop all 443 when enabled but no blocklist provided + fmt.Fprintf(&b, "add rule inet %s %s tcp dport 443 drop\n", tableName, chainName) + } else { + if len(dohBlockV4) > 0 { + fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s tcp dport 443 drop\n", tableName, chainName, dohBlockV4Set) + } + if len(dohBlockV6) > 0 { + fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s tcp dport 443 drop\n", tableName, chainName, dohBlockV6Set) + } + } + } + fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s drop\n", tableName, chainName, denyV4Set) + fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s drop\n", tableName, chainName, denyV6Set) + fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s accept\n", tableName, chainName, dynAllowV4Set) + fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s accept\n", tableName, chainName, dynAllowV6Set) + fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s accept\n", tableName, chainName, allowV4Set) + fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s accept\n", tableName, chainName, allowV6Set) + if chainPolicy == "drop" { + fmt.Fprintf(&b, "add rule inet %s %s drop\n", tableName, chainName) + } + + return b.String(), nil +} + +func writeElements(b *strings.Builder, setName string, elems []string) { + if len(elems) == 0 { + return + } + fmt.Fprintf(b, "add element inet %s %s { %s }\n", tableName, setName, strings.Join(elems, ", ")) +} + +func defaultRunner(ctx context.Context, script string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "nft", "-f", "-") + cmd.Stdin = strings.NewReader(script) + output, err := cmd.CombinedOutput() + if err != nil { + return output, fmt.Errorf("nft apply failed: %w (output: %s)", err, strings.TrimSpace(string(output))) + } + return output, nil +} + +func isMissingTableError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "no such file or directory") && strings.Contains(msg, "delete table inet "+tableName) +} + +func removeDeleteTableLine(script string) string { + lines := strings.Split(script, "\n") + var filtered []string + for _, l := range lines { + if strings.HasPrefix(l, "delete table inet "+tableName) { + continue + } + if strings.TrimSpace(l) == "" { + continue + } + filtered = append(filtered, l) + } + return strings.Join(filtered, "\n") +} diff --git a/components/egress/pkg/nftables/manager_test.go b/components/egress/pkg/nftables/manager_test.go new file mode 100644 index 0000000..826d229 --- /dev/null +++ b/components/egress/pkg/nftables/manager_test.go @@ -0,0 +1,200 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nftables + +import ( + "context" + "fmt" + "net/netip" + "testing" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/stretchr/testify/require" +) + +func TestApplyStatic_BuildsRuleset_DefaultDeny(t *testing.T) { + var rendered string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }) + + p, err := policy.ParsePolicy(`{ + "defaultAction":"deny", + "egress":[ + {"action":"allow","target":"1.1.1.1"}, + {"action":"allow","target":"2.2.0.0/16"}, + {"action":"deny","target":"2001:db8::/32"} + ] + }`) + require.NoError(t, err, "unexpected parse error") + + require.NoError(t, m.ApplyStatic(context.Background(), p), "ApplyStatic returned error") + + expectContains(t, rendered, "add chain inet opensandbox egress { type filter hook output priority 0; policy drop; }") + expectContains(t, rendered, "add rule inet opensandbox egress ct state established,related accept") + expectContains(t, rendered, "add rule inet opensandbox egress meta mark 0x1 accept") + expectContains(t, rendered, "add rule inet opensandbox egress oifname \"lo\" accept") + expectContains(t, rendered, "add rule inet opensandbox egress tcp dport 853 drop") + expectContains(t, rendered, "add rule inet opensandbox egress udp dport 853 drop") + expectContains(t, rendered, "add set inet opensandbox dyn_allow_v4 { type ipv4_addr; timeout 360s; }") + expectContains(t, rendered, "add set inet opensandbox dyn_allow_v6 { type ipv6_addr; timeout 360s; }") + expectContains(t, rendered, "add element inet opensandbox allow_v4 { 1.1.1.1, 2.2.0.0/16 }") + expectContains(t, rendered, "add element inet opensandbox deny_v6 { 2001:db8::/32 }") + expectContains(t, rendered, "add rule inet opensandbox egress ip daddr @dyn_allow_v4 accept") + expectContains(t, rendered, "add rule inet opensandbox egress ip6 daddr @dyn_allow_v6 accept") + expectContains(t, rendered, "add rule inet opensandbox egress drop") +} + +func TestApplyStatic_DefaultDenyFallbackRuleUsesPlainDrop(t *testing.T) { + var rendered string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }) + + p, err := policy.ParsePolicy(`{"defaultAction":"deny","egress":[]}`) + require.NoError(t, err) + require.NoError(t, m.ApplyStatic(context.Background(), p)) + + expectContains(t, rendered, "add chain inet opensandbox egress { type filter hook output priority 0; policy drop; }") + expectContains(t, rendered, "add rule inet opensandbox egress drop") + require.NotContains(t, rendered, "counter drop", "counter expression is not supported in the QA pod netns") +} + +func TestApplyStatic_DefaultAllowUsesAcceptPolicy(t *testing.T) { + var rendered string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }) + + p, err := policy.ParsePolicy(`{ + "defaultAction":"allow", + "egress":[{"action":"deny","target":"10.0.0.0/8"}] + }`) + require.NoError(t, err, "unexpected parse error") + + require.NoError(t, m.ApplyStatic(context.Background(), p), "ApplyStatic returned error") + + expectContains(t, rendered, "policy accept;") + expectContains(t, rendered, "add rule inet opensandbox egress tcp dport 853 drop") + require.NotContains(t, rendered, " egress drop", "did not expect final drop rule when defaultAction is allow:\n%s", rendered) + expectContains(t, rendered, "add element inet opensandbox deny_v4 { 10.0.0.0/8 }") +} + +func expectContains(t *testing.T, s, substr string) { + t.Helper() + require.Contains(t, s, substr, "expected rendered ruleset to contain %q\nrendered:\n%s", substr, s) +} + +func TestApplyStatic_RetryWhenTableMissing(t *testing.T) { + var calls int + var scripts []string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + calls++ + scripts = append(scripts, script) + if calls == 1 { + return nil, fmt.Errorf("nft apply failed: exit status 1 (output: /dev/stdin:1:19-29: Error: No such file or directory; did you mean table ‘opensandbox’ in family inet?\ndelete table inet opensandbox\n ^^^^^^^^^^^)") + } + return nil, nil + }) + + p, _ := policy.ParsePolicy(`{"egress":[]}`) + require.NoError(t, m.ApplyStatic(context.Background(), p), "expected retry to succeed") + require.Equal(t, 2, calls, "expected 2 calls (fail then retry)") + require.GreaterOrEqual(t, len(scripts), 2, "expected second attempt script to be recorded") + require.NotContains(t, scripts[1], "delete table inet opensandbox", "expected second attempt to drop delete-table line") +} + +func TestApplyStatic_DoHBlocklist(t *testing.T) { + var rendered string + opts := Options{ + BlockDoT: true, + BlockDoH443: true, + DoHBlocklistV4: []string{"9.9.9.9"}, + DoHBlocklistV6: []string{"2001:db8::/32"}, + } + m := NewManagerWithRunnerAndOptions(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }, opts) + + p, _ := policy.ParsePolicy(`{"defaultAction":"allow","egress":[]}`) + require.NoError(t, m.ApplyStatic(context.Background(), p), "ApplyStatic returned error") + + expectContains(t, rendered, "add set inet opensandbox doh_block_v4 { type ipv4_addr; flags interval; }") + expectContains(t, rendered, "add element inet opensandbox doh_block_v4 { 9.9.9.9 }") + expectContains(t, rendered, "add rule inet opensandbox egress ip daddr @doh_block_v4 tcp dport 443 drop") + expectContains(t, rendered, "add rule inet opensandbox egress ip6 daddr @doh_block_v6 tcp dport 443 drop") +} + +func TestAddResolvedIPs_BuildsDynamicElements(t *testing.T) { + var rendered string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }) + ips := []ResolvedIP{ + {Addr: netip.MustParseAddr("1.1.1.1"), TTL: 120 * time.Second}, + {Addr: netip.MustParseAddr("2001:db8::1"), TTL: 60 * time.Second}, + } + require.NoError(t, m.AddResolvedIPs(context.Background(), ips), "AddResolvedIPs returned error") + expectContains(t, rendered, "add element inet opensandbox dyn_allow_v4 { 1.1.1.1 timeout 180s }") + expectContains(t, rendered, "add element inet opensandbox dyn_allow_v6 { 2001:db8::1 timeout 120s }") +} + +func TestAddResolvedIPs_ClampsTTL(t *testing.T) { + var rendered string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }) + ips := []ResolvedIP{ + {Addr: netip.MustParseAddr("10.0.0.1"), TTL: 10 * time.Second}, + {Addr: netip.MustParseAddr("10.0.0.2"), TTL: 9999 * time.Second}, + } + require.NoError(t, m.AddResolvedIPs(context.Background(), ips), "AddResolvedIPs returned error") + expectContains(t, rendered, "10.0.0.1 timeout 70s") + expectContains(t, rendered, "10.0.0.2 timeout 360s") +} + +func TestAddResolvedIPs_EmptyNoOp(t *testing.T) { + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + require.FailNow(t, "runner should not be called for empty ips") + return nil, nil + }) + require.NoError(t, m.AddResolvedIPs(context.Background(), nil), "AddResolvedIPs returned error") + require.NoError(t, m.AddResolvedIPs(context.Background(), []ResolvedIP{}), "AddResolvedIPs returned error") +} + +func TestApplyStatic_NormalizesOverlappingAllow(t *testing.T) { + var rendered string + m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { + rendered = script + return nil, nil + }) + p, err := policy.ParsePolicy(`{ + "defaultAction":"deny", + "egress":[ + {"action":"allow","target":"100.64.0.0/10"}, + {"action":"allow","target":"100.100.2.136"} + ] + }`) + require.NoError(t, err) + require.NoError(t, m.ApplyStatic(context.Background(), p)) + expectContains(t, rendered, "add element inet opensandbox allow_v4 { 100.64.0.0/10 }") +} diff --git a/components/egress/pkg/policy/always_rules.go b/components/egress/pkg/policy/always_rules.go new file mode 100644 index 0000000..b934384 --- /dev/null +++ b/components/egress/pkg/policy/always_rules.go @@ -0,0 +1,108 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +// Fixed paths for operator-managed lists. Missing files are ignored (no effect). +const ( + alwaysDenyFilePath = "/var/egress/rules/deny.always" + alwaysAllowFilePath = "/var/egress/rules/allow.always" +) + +// LoadAlwaysRuleFiles loads optional /var/egress/rules/deny|allow.always (ignore if missing); errors on unreadable. +func LoadAlwaysRuleFiles() (deny, allow []EgressRule, err error) { + deny, err = loadAlwaysRuleFile(alwaysDenyFilePath, ActionDeny) + if err != nil { + return nil, nil, err + } + allow, err = loadAlwaysRuleFile(alwaysAllowFilePath, ActionAllow) + if err != nil { + return nil, nil, err + } + log.Infof("loaded %d always-deny rule(s) from %s", len(deny), alwaysDenyFilePath) + log.Infof("loaded %d always-allow rule(s) from %s", len(allow), alwaysAllowFilePath) + return deny, allow, nil +} + +func loadAlwaysRuleFile(path, action string) ([]EgressRule, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return parseAlwaysRuleLines(data, action, path) +} + +func parseAlwaysRuleLines(data []byte, action, pathForErr string) ([]EgressRule, error) { + var out []EgressRule + sc := bufio.NewScanner(strings.NewReader(string(data))) + lineNum := 0 + for sc.Scan() { + lineNum++ + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + rule, err := ParseValidatedEgressRule(action, line) + if err != nil { + return nil, fmt.Errorf("%s line %d: %w", pathForErr, lineNum, err) + } + out = append(out, rule) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("%s: %w", pathForErr, err) + } + return out, nil +} + +func ParseValidatedEgressRule(action, target string) (EgressRule, error) { + p := NetworkPolicy{ + DefaultAction: ActionDeny, + Egress: []EgressRule{{Action: action, Target: target}}, + } + if err := normalizePolicy(&p); err != nil { + return EgressRule{}, err + } + return p.Egress[0], nil +} + +// MergeAlwaysOverlay prepends always-deny, then always-allow, then user egress. +// First matching domain rule in Evaluate wins; deny.always therefore overrides +// user rules and allow.always for the same target. Between two always files, +// deny entries are ordered before allow entries so deny wins on duplicate targets. +func MergeAlwaysOverlay(user *NetworkPolicy, alwaysDeny, alwaysAllow []EgressRule) *NetworkPolicy { + if user == nil { + user = DefaultDenyPolicy() + } + out := *user + out.Egress = append([]EgressRule(nil), user.Egress...) + n := len(alwaysDeny) + len(alwaysAllow) + len(out.Egress) + merged := make([]EgressRule, 0, n) + merged = append(merged, alwaysDeny...) + merged = append(merged, alwaysAllow...) + merged = append(merged, out.Egress...) + out.Egress = merged + return ensureDefaults(&out) +} diff --git a/components/egress/pkg/policy/always_rules_test.go b/components/egress/pkg/policy/always_rules_test.go new file mode 100644 index 0000000..02b1021 --- /dev/null +++ b/components/egress/pkg/policy/always_rules_test.go @@ -0,0 +1,123 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMergeAlwaysOverlay_OrderAndPrecedence(t *testing.T) { + user, err := ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"evil.com"}]}`) + require.NoError(t, err) + + deny, err := ParseValidatedEgressRule(ActionDeny, "evil.com") + require.NoError(t, err) + merged := MergeAlwaysOverlay(user, []EgressRule{deny}, nil) + require.Equal(t, ActionDeny, merged.Evaluate("evil.com."), "always deny must override user allow") + + user2, err := ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"deny","target":"good.com"}]}`) + require.NoError(t, err) + allow, err := ParseValidatedEgressRule(ActionAllow, "good.com") + require.NoError(t, err) + merged2 := MergeAlwaysOverlay(user2, nil, []EgressRule{allow}) + require.Equal(t, ActionAllow, merged2.Evaluate("good.com."), "always allow must override user deny") +} + +func TestMergeAlwaysOverlay_DenyAlwaysBeatsAllowAlways(t *testing.T) { + user := DefaultDenyPolicy() + deny, err := ParseValidatedEgressRule(ActionDeny, "x.com") + require.NoError(t, err) + allow, err := ParseValidatedEgressRule(ActionAllow, "x.com") + require.NoError(t, err) + merged := MergeAlwaysOverlay(user, []EgressRule{deny}, []EgressRule{allow}) + require.Equal(t, ActionDeny, merged.Evaluate("x.com.")) +} + +func TestParseAlwaysRuleLines(t *testing.T) { + raw := "# c\n\n192.0.2.1\n2001:db8::/32\n*.foo.test\n" + got, err := parseAlwaysRuleLines([]byte(raw), ActionDeny, "test") + require.NoError(t, err) + require.Len(t, got, 3) +} + +func TestLoadAlwaysRuleFile_Missing(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nope") + got, err := loadAlwaysRuleFile(path, ActionDeny) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestParseValidatedEgressRule_EmptyTarget(t *testing.T) { + _, err := ParseValidatedEgressRule(ActionDeny, "") + require.Error(t, err) +} + +func TestAlwaysRuleLoader_RefreshIntervalAndReloadByMTime(t *testing.T) { + dir := t.TempDir() + denyPath := filepath.Join(dir, "deny.always") + allowPath := filepath.Join(dir, "allow.always") + require.NoError(t, os.WriteFile(denyPath, []byte("1.1.1.1\n"), 0o644)) + + loader := newAlwaysRuleLoader(time.Minute, denyPath, allowPath) + t0 := time.Unix(1000, 0) + + deny, allow, changed, err := loader.RefreshIfDue(t0) + require.NoError(t, err) + require.True(t, changed) + require.Len(t, deny, 1) + require.Nil(t, allow) + require.Equal(t, "1.1.1.1", deny[0].Target) + + require.NoError(t, os.WriteFile(denyPath, []byte("2.2.2.2\n"), 0o644)) + require.NoError(t, os.Chtimes(denyPath, t0.Add(10*time.Second), t0.Add(10*time.Second))) + deny, _, changed, err = loader.RefreshIfDue(t0.Add(30 * time.Second)) + require.NoError(t, err) + require.False(t, changed, "should skip checks before refresh interval") + require.Len(t, deny, 1) + require.Equal(t, "1.1.1.1", deny[0].Target, "cached rules should remain before interval") + + deny, _, changed, err = loader.RefreshIfDue(t0.Add(61 * time.Second)) + require.NoError(t, err) + require.True(t, changed, "mtime changed after interval, should reload") + require.Len(t, deny, 1) + require.Equal(t, "2.2.2.2", deny[0].Target) +} + +func TestAlwaysRuleLoader_DeleteFileRemovesRules(t *testing.T) { + dir := t.TempDir() + denyPath := filepath.Join(dir, "deny.always") + allowPath := filepath.Join(dir, "allow.always") + require.NoError(t, os.WriteFile(denyPath, []byte("3.3.3.3\n"), 0o644)) + + loader := newAlwaysRuleLoader(time.Minute, denyPath, allowPath) + t0 := time.Unix(2000, 0) + + deny, _, changed, err := loader.RefreshIfDue(t0) + require.NoError(t, err) + require.True(t, changed) + require.Len(t, deny, 1) + + require.NoError(t, os.Remove(denyPath)) + deny, _, changed, err = loader.RefreshIfDue(t0.Add(61 * time.Second)) + require.NoError(t, err) + require.True(t, changed, "file deletion should be treated as rules removed") + require.Nil(t, deny) +} diff --git a/components/egress/pkg/policy/domain_index.go b/components/egress/pkg/policy/domain_index.go new file mode 100644 index 0000000..125624e --- /dev/null +++ b/components/egress/pkg/policy/domain_index.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import "strings" + +// compiledDomainRule records first-seen index for a pattern; match() picks lowest index on conflicts. +type compiledDomainRule struct { + index int + action string +} + +// compiledDomainIndex: exact map + wildcard suffix map; order in Evaluate follows merged egress order. +type compiledDomainIndex struct { + exact map[string]compiledDomainRule + wildcard map[string]compiledDomainRule +} + +func compileDomainIndex(egress []EgressRule) *compiledDomainIndex { + idx := &compiledDomainIndex{ + exact: make(map[string]compiledDomainRule), + wildcard: make(map[string]compiledDomainRule), + } + for i, r := range egress { + if r.targetKind != targetDomain { + continue + } + pattern := strings.ToLower(strings.TrimSpace(r.Target)) + if pattern == "" { + continue + } + cr := compiledDomainRule{ + index: i, + action: r.Action, + } + if strings.HasPrefix(pattern, "*.") { + suffix := strings.TrimPrefix(pattern, "*") + if _, exists := idx.wildcard[suffix]; !exists { + idx.wildcard[suffix] = cr + } + continue + } + if _, exists := idx.exact[pattern]; !exists { + idx.exact[pattern] = cr + } + } + return idx +} + +func (idx *compiledDomainIndex) match(domain string) (string, bool) { + if idx == nil || domain == "" { + return "", false + } + + var best compiledDomainRule + matched := false + + if rule, ok := idx.exact[domain]; ok { + if rule.index == 0 { + return rule.action, true + } + best = rule + matched = true + } + + for cursor := domain; ; { + dot := strings.IndexByte(cursor, '.') + if dot < 0 { + break + } + suffix := cursor[dot:] + if rule, ok := idx.wildcard[suffix]; ok { + if rule.index == 0 { + return rule.action, true + } + if !matched || rule.index < best.index { + best = rule + matched = true + } + } + cursor = cursor[dot+1:] + } + + if !matched { + return "", false + } + return best.action, true +} diff --git a/components/egress/pkg/policy/domain_set.go b/components/egress/pkg/policy/domain_set.go new file mode 100644 index 0000000..9ce45ff --- /dev/null +++ b/components/egress/pkg/policy/domain_set.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import "strings" + +// DomainSet is a read-only set of domain patterns used for membership checks +// (e.g. suppressing log output for selected hosts). Patterns use the same +// exact / wildcard-suffix semantics as policy egress rules: "foo.com" matches +// the bare host; "*.foo.com" matches any subdomain but not the bare host. +type DomainSet struct { + idx *compiledDomainIndex +} + +// NewDomainSet compiles the given patterns into a DomainSet. Empty/whitespace +// entries are ignored. Patterns are assumed to be already validated as domain +// targets (e.g. via LoadLogSkipFile); invalid entries are not rejected here. +func NewDomainSet(patterns []string) *DomainSet { + if len(patterns) == 0 { + return &DomainSet{} + } + rules := make([]EgressRule, 0, len(patterns)) + for _, p := range patterns { + p = strings.TrimSpace(p) + if p == "" { + continue + } + rules = append(rules, EgressRule{Action: ActionAllow, Target: p, targetKind: targetDomain}) + } + if len(rules) == 0 { + return &DomainSet{} + } + return &DomainSet{idx: compileDomainIndex(rules)} +} + +// Match reports whether host matches any pattern in the set. Hosts are +// normalised by lowercasing and stripping a trailing dot before lookup. +func (d *DomainSet) Match(host string) bool { + if d == nil || d.idx == nil || host == "" { + return false + } + h := strings.ToLower(strings.TrimSuffix(host, ".")) + _, ok := d.idx.match(h) + return ok +} + +// Empty reports whether the set has no patterns. +func (d *DomainSet) Empty() bool { + return d == nil || d.idx == nil +} diff --git a/components/egress/pkg/policy/domain_set_test.go b/components/egress/pkg/policy/domain_set_test.go new file mode 100644 index 0000000..1a627b5 --- /dev/null +++ b/components/egress/pkg/policy/domain_set_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDomainSet_ExactMatch(t *testing.T) { + d := NewDomainSet([]string{"metadata.internal"}) + require.True(t, d.Match("metadata.internal"), "exact pattern must match identical host") + require.False(t, d.Match("other.internal"), "different host must not match") + require.False(t, d.Match("sub.metadata.internal"), "exact pattern must not match subdomain") +} + +func TestDomainSet_WildcardMatchesSubdomainOnly(t *testing.T) { + d := NewDomainSet([]string{"*.cluster.local"}) + require.True(t, d.Match("svc.cluster.local"), "wildcard must match one-level subdomain") + require.True(t, d.Match("a.b.cluster.local"), "wildcard must match deeper subdomain") + require.False(t, d.Match("cluster.local"), "wildcard *.foo must not match bare foo (matches compiledDomainIndex semantics)") + require.False(t, d.Match("notcluster.local"), "wildcard suffix must align on dot boundary") +} + +func TestDomainSet_CombinedExactAndWildcard(t *testing.T) { + d := NewDomainSet([]string{"cluster.local", "*.cluster.local"}) + require.True(t, d.Match("cluster.local"), "exact entry covers bare host") + require.True(t, d.Match("svc.cluster.local"), "wildcard entry covers subdomain") +} + +func TestDomainSet_CaseInsensitive(t *testing.T) { + d := NewDomainSet([]string{"Metadata.Internal"}) + require.True(t, d.Match("METADATA.INTERNAL"), "match must be case-insensitive") +} + +func TestDomainSet_TrailingDotStripped(t *testing.T) { + d := NewDomainSet([]string{"metadata.internal"}) + require.True(t, d.Match("metadata.internal."), "trailing dot in queried host must be stripped before match") +} + +func TestDomainSet_EmptyOrNilNeverMatches(t *testing.T) { + require.False(t, NewDomainSet(nil).Match("foo.com")) + require.False(t, NewDomainSet([]string{}).Match("foo.com")) + require.False(t, NewDomainSet([]string{"", " "}).Match("foo.com"), "whitespace-only entries are ignored") + var d *DomainSet + require.False(t, d.Match("foo.com"), "nil receiver must not panic and must return false") +} + +func TestDomainSet_Empty(t *testing.T) { + require.True(t, NewDomainSet(nil).Empty()) + require.True(t, NewDomainSet([]string{" "}).Empty(), "whitespace-only patterns produce empty set") + require.False(t, NewDomainSet([]string{"foo.com"}).Empty()) + var d *DomainSet + require.True(t, d.Empty(), "nil receiver reports empty") +} + +func TestDomainSet_EmptyHostNeverMatches(t *testing.T) { + d := NewDomainSet([]string{"foo.com"}) + require.False(t, d.Match(""), "empty host must not match anything") +} diff --git a/components/egress/pkg/policy/evaluate_benchmark_test.go b/components/egress/pkg/policy/evaluate_benchmark_test.go new file mode 100644 index 0000000..4ed426d --- /dev/null +++ b/components/egress/pkg/policy/evaluate_benchmark_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "fmt" + "strings" + "testing" +) + +// BenchmarkEvaluateLinearMiss benchmarks the linear evaluation of a domain that is not present in the policy. +// +// goos: darwin +// goarch: arm64 +// pkg: github.com/alibaba/opensandbox/egress/pkg/policy +// cpu: Apple M2 Pro +// BenchmarkEvaluateLinearMiss +// BenchmarkEvaluateLinearMiss/rules_500 +// BenchmarkEvaluateLinearMiss/rules_500-10 54367 21454 ns/op 0 B/op 0 allocs/op +// BenchmarkEvaluateLinearMiss/rules_1000 +// BenchmarkEvaluateLinearMiss/rules_1000-10 27912 42489 ns/op 0 B/op 0 allocs/op +// BenchmarkEvaluateLinearMiss/rules_10000 +// BenchmarkEvaluateLinearMiss/rules_10000-10 2684 446589 ns/op 0 B/op 0 allocs/op +func BenchmarkEvaluateLinearMiss(b *testing.B) { + for _, ruleCount := range []int{500, 1000, 10000} { + b.Run(fmt.Sprintf("rules_%d", ruleCount), func(b *testing.B) { + p := buildDomainOnlyPolicy(ruleCount, false) + query := "not-found.example.com." + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = p.evaluateLinear(strings.TrimSuffix(strings.ToLower(query), ".")) + } + }) + } +} + +// BenchmarkEvaluateCompiledIndexMiss benchmarks the compiled evaluation of a domain that is not present in the policy. +// +// goos: darwin +// goarch: arm64 +// pkg: github.com/alibaba/opensandbox/egress/pkg/policy +// cpu: Apple M2 Pro +// BenchmarkEvaluateCompiledIndexMiss +// BenchmarkEvaluateCompiledIndexMiss/rules_500 +// BenchmarkEvaluateCompiledIndexMiss/rules_500-10 25609082 45.57 ns/op 0 B/op 0 allocs/op +// BenchmarkEvaluateCompiledIndexMiss/rules_1000 +// BenchmarkEvaluateCompiledIndexMiss/rules_1000-10 26226450 46.85 ns/op 0 B/op 0 allocs/op +// BenchmarkEvaluateCompiledIndexMiss/rules_10000 +// BenchmarkEvaluateCompiledIndexMiss/rules_10000-10 26390857 45.27 ns/op 0 B/op 0 allocs/op +func BenchmarkEvaluateCompiledIndexMiss(b *testing.B) { + for _, ruleCount := range []int{500, 1000, 10000} { + b.Run(fmt.Sprintf("rules_%d", ruleCount), func(b *testing.B) { + p := buildDomainOnlyPolicy(ruleCount, true) + query := "not-found.example.com." + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.Evaluate(query) + } + }) + } +} + +func buildDomainOnlyPolicy(ruleCount int, compiled bool) *NetworkPolicy { + egress := make([]EgressRule, 0, ruleCount) + for i := 0; i < ruleCount; i++ { + egress = append(egress, EgressRule{ + Action: ActionAllow, + Target: fmt.Sprintf("rule-%d.example.com", i), + targetKind: targetDomain, + }) + } + p := &NetworkPolicy{ + Egress: egress, + DefaultAction: ActionDeny, + } + if compiled { + return ensureDefaults(p) + } + return p +} diff --git a/components/egress/pkg/policy/log_skip.go b/components/egress/pkg/policy/log_skip.go new file mode 100644 index 0000000..8ed973c --- /dev/null +++ b/components/egress/pkg/policy/log_skip.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Operator-managed list of domain patterns whose successful DNS resolutions +// should not produce an "egress outbound" log line. Missing file is ignored. +// One-shot load at startup; not hot-reloaded. +const logSkipFilePath = "/var/egress/rules/log_skip.always" + +// LoadLogSkipFile reads optional /var/egress/rules/log_skip.always. +// Each non-empty/non-comment line is a domain pattern: "foo.com" (exact) or +// "*.foo.com" (subdomain wildcard). IP/CIDR entries are rejected — only +// host/domain entries are meaningful for matching against DNS query names. +func LoadLogSkipFile() ([]string, error) { + return loadLogSkipFile(logSkipFilePath) +} + +func loadLogSkipFile(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return parseLogSkipLines(data, path) +} + +func parseLogSkipLines(data []byte, pathForErr string) ([]string, error) { + var out []string + sc := bufio.NewScanner(strings.NewReader(string(data))) + lineNum := 0 + for sc.Scan() { + lineNum++ + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + rule, err := ParseValidatedEgressRule(ActionAllow, line) + if err != nil { + return nil, fmt.Errorf("%s line %d: %w", pathForErr, lineNum, err) + } + if rule.targetKind != targetDomain { + return nil, fmt.Errorf("%s line %d: %q is not a domain pattern; only host/domain entries are supported", pathForErr, lineNum, line) + } + out = append(out, rule.Target) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("%s: %w", pathForErr, err) + } + return out, nil +} diff --git a/components/egress/pkg/policy/log_skip_test.go b/components/egress/pkg/policy/log_skip_test.go new file mode 100644 index 0000000..bafbeb2 --- /dev/null +++ b/components/egress/pkg/policy/log_skip_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadLogSkipFile_ParsesDomainsAndComments(t *testing.T) { + path := filepath.Join(t.TempDir(), "log_skip.always") + body := []byte("# comment line\n" + + "metadata.internal\n" + + "\n" + + "*.cluster.local\n" + + " # indented comment is also ignored\n" + + " Foo.Example.Com \n") + require.NoError(t, os.WriteFile(path, body, 0o644)) + + patterns, err := loadLogSkipFile(path) + require.NoError(t, err) + // Raw user case is preserved here; DomainSet lowercases at compile/match time + // so downstream matching is still case-insensitive. + require.Equal(t, []string{"metadata.internal", "*.cluster.local", "Foo.Example.Com"}, patterns, + "comments and blank lines are dropped; surrounding whitespace stripped; order preserved") +} + +func TestLoadLogSkipFile_BlankAndCommentsOnlyReturnsNil(t *testing.T) { + path := filepath.Join(t.TempDir(), "log_skip.always") + require.NoError(t, os.WriteFile(path, []byte("# just a comment\n\n \n# another\n"), 0o644)) + + patterns, err := loadLogSkipFile(path) + require.NoError(t, err) + require.Nil(t, patterns, "file with no effective entries must produce nil patterns") +} + +func TestLoadLogSkipFile_MissingFileIsNotError(t *testing.T) { + patterns, err := loadLogSkipFile(filepath.Join(t.TempDir(), "does-not-exist")) + require.NoError(t, err, "missing file is a soft no-op") + require.Nil(t, patterns) +} + +func TestLoadLogSkipFile_RejectsIPEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "log_skip.always") + require.NoError(t, os.WriteFile(path, []byte("1.2.3.4\n"), 0o644)) + + _, err := loadLogSkipFile(path) + require.Error(t, err, "IP entries must be rejected — log skip matches DNS query names") + require.Contains(t, err.Error(), "not a domain pattern") +} + +func TestLoadLogSkipFile_RejectsCIDREntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "log_skip.always") + require.NoError(t, os.WriteFile(path, []byte("10.0.0.0/8\n"), 0o644)) + + _, err := loadLogSkipFile(path) + require.Error(t, err, "CIDR entries must be rejected") + require.Contains(t, err.Error(), "not a domain pattern") +} + +func TestLoadLogSkipFile_ReportsLineNumberOnRejectedEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "log_skip.always") + require.NoError(t, os.WriteFile(path, []byte("ok.example.com\n10.0.0.0/8\n"), 0o644)) + + _, err := loadLogSkipFile(path) + require.Error(t, err) + require.Contains(t, err.Error(), "line 2", "error must point at the offending line") + require.Contains(t, err.Error(), "not a domain pattern") +} diff --git a/components/egress/pkg/policy/persist.go b/components/egress/pkg/policy/persist.go new file mode 100644 index 0000000..705b96c --- /dev/null +++ b/components/egress/pkg/policy/persist.go @@ -0,0 +1,118 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "encoding/json" + "io/fs" + "os" + "strings" + + "github.com/alibaba/opensandbox/egress/pkg/log" +) + +type PolicyInitialSource string + +const ( + PolicyFromFile PolicyInitialSource = "policy_file" + PolicyFromEnv PolicyInitialSource = "env" + PolicyFromDefault PolicyInitialSource = "default" +) + +func loadPolicyFromEnvVar(envName string) (*NetworkPolicy, error) { + raw := os.Getenv(envName) + if raw == "" { + return DefaultDenyPolicy(), nil + } + return ParsePolicy(raw) +} + +func loadPolicyFromEnvWithSource(envName string) (*NetworkPolicy, PolicyInitialSource, error) { + p, err := loadPolicyFromEnvVar(envName) + if err != nil { + return nil, "", err + } + raw := strings.TrimSpace(os.Getenv(envName)) + if raw == "" { + return p, PolicyFromDefault, nil + } + log.Infof("loaded egress policy from env %s: %s", envName, raw) + return p, PolicyFromEnv, nil +} + +func LoadInitialPolicy(policyFile, envName string) (*NetworkPolicy, error) { + p, _, err := LoadInitialPolicyDetailed(policyFile, envName) + return p, err +} + +func LoadInitialPolicyDetailed(policyFile, envName string) (*NetworkPolicy, PolicyInitialSource, error) { + policyFile = strings.TrimSpace(policyFile) + if policyFile == "" { + return loadPolicyFromEnvWithSource(envName) + } + + data, err := os.ReadFile(policyFile) + if err != nil { + if os.IsNotExist(err) { + return loadPolicyFromEnvWithSource(envName) + } + return nil, "", err + } + + raw := strings.TrimSpace(string(data)) + if raw == "" { + log.Warnf("egress policy file %s is empty; falling back to %s", policyFile, envName) + return loadPolicyFromEnvWithSource(envName) + } + + pol, err := ParsePolicy(raw) + if err != nil { + log.Warnf("egress policy file %s is invalid: %v; falling back to %s", policyFile, err, envName) + return loadPolicyFromEnvWithSource(envName) + } + + log.Infof("loaded egress policy from %s: %s", policyFile, raw) + return pol, PolicyFromFile, nil +} + +// SavePolicyFile rewrites the policy file atomically (JSON indent, fsync) when path is set. +func SavePolicyFile(path string, p *NetworkPolicy) error { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + if p == nil { + p = DefaultDenyPolicy() + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + mode := fs.FileMode(0o600) + if info, err := os.Stat(path); err == nil { + mode = info.Mode() & fs.ModePerm + } + if err := os.WriteFile(path, data, mode); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} diff --git a/components/egress/pkg/policy/persist_test.go b/components/egress/pkg/policy/persist_test.go new file mode 100644 index 0000000..cba46f2 --- /dev/null +++ b/components/egress/pkg/policy/persist_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadPolicyFromEnvVar(t *testing.T) { + const envName = "TEST_EGRESS_POLICY" + t.Setenv(envName, `{"defaultAction":"deny","egress":[{"action":"allow","target":"example.com"}]}`) + + pol, err := loadPolicyFromEnvVar(envName) + require.NoError(t, err, "unexpected error") + require.NotNil(t, pol, "expected parsed policy") + require.Equal(t, ActionAllow, pol.Evaluate("example.com."), "expected parsed policy to allow example.com") + + t.Setenv(envName, "") + pol, err = loadPolicyFromEnvVar(envName) + require.NoError(t, err, "unexpected error on empty env") + require.NotNil(t, pol, "expected default deny policy when env is empty") + require.Equal(t, ActionDeny, pol.DefaultAction, "expected default deny when env is empty") +} + +func TestLoadInitialPolicy_EmptyPathUsesEnv(t *testing.T) { + const envName = "TEST_EGRESS_POLICY_LOAD1" + t.Setenv(envName, `{"defaultAction":"deny","egress":[{"action":"allow","target":"a.example.com"}]}`) + + pol, err := LoadInitialPolicy("", envName) + require.NoError(t, err) + require.Equal(t, ActionAllow, pol.Evaluate("a.example.com.")) +} + +func TestLoadInitialPolicy_MissingFileUsesEnv(t *testing.T) { + const envName = "TEST_EGRESS_POLICY_LOAD2" + t.Setenv(envName, `{"defaultAction":"deny","egress":[{"action":"allow","target":"b.example.com"}]}`) + + pol, err := LoadInitialPolicy(filepath.Join(t.TempDir(), "nonexistent-policy.json"), envName) + require.NoError(t, err) + require.Equal(t, ActionAllow, pol.Evaluate("b.example.com.")) +} + +func TestLoadInitialPolicy_ValidFileOverridesEnv(t *testing.T) { + const envName = "TEST_EGRESS_POLICY_LOAD3" + t.Setenv(envName, `{"defaultAction":"deny","egress":[{"action":"allow","target":"from-env.example.com"}]}`) + + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + err := os.WriteFile(path, []byte(`{"defaultAction":"deny","egress":[{"action":"allow","target":"from-file.example.com"}]}`), 0o600) + require.NoError(t, err) + + pol, err := LoadInitialPolicy(path, envName) + require.NoError(t, err) + require.Equal(t, ActionAllow, pol.Evaluate("from-file.example.com.")) + require.Equal(t, ActionDeny, pol.Evaluate("from-env.example.com.")) +} + +func TestLoadInitialPolicy_InvalidFileFallsBackToEnv(t *testing.T) { + const envName = "TEST_EGRESS_POLICY_LOAD4" + t.Setenv(envName, `{"defaultAction":"deny","egress":[{"action":"allow","target":"fallback.example.com"}]}`) + + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + require.NoError(t, os.WriteFile(path, []byte(`not-json`), 0o600)) + + pol, err := LoadInitialPolicy(path, envName) + require.NoError(t, err) + require.Equal(t, ActionAllow, pol.Evaluate("fallback.example.com.")) +} + +func TestSavePolicyFile_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.json") + pol, err := ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"x.example.com"}]}`) + require.NoError(t, err) + + require.NoError(t, SavePolicyFile(path, pol)) + + p2, err := LoadInitialPolicy(path, "TEST_EGRESS_UNUSED_ENV") + require.NoError(t, err) + require.Equal(t, ActionAllow, p2.Evaluate("x.example.com.")) +} + +func TestSavePolicyFile_NilWritesDefaultDeny(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nil.json") + require.NoError(t, SavePolicyFile(path, nil)) + p2, err := LoadInitialPolicy(path, "TEST_EGRESS_UNUSED_ENV2") + require.NoError(t, err) + require.Equal(t, ActionDeny, p2.Evaluate("any.example.com.")) +} + +func TestSavePolicyFile_EmptyPathNoOp(t *testing.T) { + require.NoError(t, SavePolicyFile("", DefaultDenyPolicy())) +} + +func TestSavePolicyFile_PreservesMode(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "policy.json") + require.NoError(t, os.WriteFile(path, []byte(`{}`), 0o640)) + + pol, err := ParsePolicy(`{"defaultAction":"deny","egress":[]}`) + require.NoError(t, err) + require.NoError(t, SavePolicyFile(path, pol)) + + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, fs.FileMode(0o640), info.Mode()&fs.ModePerm) +} diff --git a/components/egress/pkg/policy/policy.go b/components/egress/pkg/policy/policy.go new file mode 100644 index 0000000..bb5be57 --- /dev/null +++ b/components/egress/pkg/policy/policy.go @@ -0,0 +1,254 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "encoding/json" + "fmt" + "math" + "net/netip" + "strings" +) + +const ( + ActionAllow = "allow" + ActionDeny = "deny" +) + +type targetKind int + +const ( + targetUnknown targetKind = iota + targetDomain + targetIP + targetCIDR +) + +// DefaultDenyPolicy is deny-by-default with an empty egress list. +func DefaultDenyPolicy() *NetworkPolicy { + return &NetworkPolicy{ + DefaultAction: ActionDeny, + domainIndex: compileDomainIndex(nil), + } +} + +// NetworkPolicy: JSON defaultAction + egress; domain rules use first-match (see compiled index). +type NetworkPolicy struct { + Egress []EgressRule `json:"egress"` + DefaultAction string `json:"defaultAction"` + + domainIndex *compiledDomainIndex +} + +type EgressRule struct { + Action string `json:"action"` + Target string `json:"target"` + + targetKind targetKind + ip netip.Addr + prefix netip.Prefix +} + +// ParsePolicy unmarshals JSON; empty/null/{} → default deny. defaultAction defaults to deny if unset in JSON. +func ParsePolicy(raw string) (*NetworkPolicy, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || trimmed == "null" || trimmed == "{}" { + return DefaultDenyPolicy(), nil + } + + var p NetworkPolicy + if err := json.Unmarshal([]byte(trimmed), &p); err != nil { + return nil, err + } + if err := normalizePolicy(&p); err != nil { + return nil, err + } + return ensureDefaults(&p), nil +} + +// Evaluate returns allow or deny for a query name (FQDN with or without trailing dot, lowercased). +func (p *NetworkPolicy) Evaluate(domain string) string { + if p == nil { + return ActionDeny + } + domain = strings.ToLower(strings.TrimSuffix(domain, ".")) + + if p.domainIndex != nil { + if action, ok := p.domainIndex.match(domain); ok { + if action == "" { + return ActionDeny + } + return action + } + } else { + // Keep compatibility for policies built manually without ParsePolicy/ensureDefaults. + if action, ok := p.evaluateLinear(domain); ok { + return action + } + } + if p.DefaultAction == "" { + return ActionDeny + } + return p.DefaultAction +} + +func (p *NetworkPolicy) evaluateLinear(domain string) (string, bool) { + for _, r := range p.Egress { + if r.targetKind != targetDomain { + continue + } + if r.matchesDomain(domain) { + if r.Action == "" { + return ActionDeny, true + } + return r.Action, true + } + } + return "", false +} + +func ensureDefaults(p *NetworkPolicy) *NetworkPolicy { + if p == nil { + return DefaultDenyPolicy() + } + if p.DefaultAction == "" { + p.DefaultAction = ActionDeny + } + p.domainIndex = compileDomainIndex(p.Egress) + return p +} + +func normalizePolicy(p *NetworkPolicy) error { + p.DefaultAction = strings.ToLower(strings.TrimSpace(p.DefaultAction)) + if p.DefaultAction == "" { + p.DefaultAction = ActionDeny + } + + for i := range p.Egress { + r := &p.Egress[i] + r.Action = strings.ToLower(strings.TrimSpace(r.Action)) + if r.Action == "" { + r.Action = ActionDeny + } + if r.Action != ActionAllow && r.Action != ActionDeny { + return fmt.Errorf("unsupported action %q", r.Action) + } + + r.Target = strings.TrimSpace(r.Target) + if r.Target == "" { + return fmt.Errorf("egress target cannot be empty") + } + if ip, err := netip.ParseAddr(r.Target); err == nil { + r.targetKind = targetIP + r.ip = ip + continue + } + if prefix, err := netip.ParsePrefix(r.Target); err == nil { + r.targetKind = targetCIDR + r.prefix = prefix + continue + } + r.targetKind = targetDomain + } + return nil +} + +// WithExtraAllowIPs appends per-IP allow rules (e.g. resolv nameservers, explicit upstream) so client and +// proxy can reach the same address; does not change domain-mode egress rules. +func (p *NetworkPolicy) WithExtraAllowIPs(ips []netip.Addr) *NetworkPolicy { + if p == nil || len(ips) == 0 { + return p + } + out := *p + n, m := len(p.Egress), len(ips) + if m > math.MaxInt-n { + panic("policy: egress rule slice capacity overflow") + } + out.Egress = make([]EgressRule, n, n+m) + copy(out.Egress, p.Egress) + for _, ip := range ips { + out.Egress = append(out.Egress, EgressRule{ + Action: ActionAllow, + Target: ip.String(), + targetKind: targetIP, + ip: ip, + }) + } + return &out +} + +// StaticIPSets buckets static IP/CIDR egress into allow/deny v4/v6 for nft element generation. +func (p *NetworkPolicy) StaticIPSets() (allowV4, allowV6, denyV4, denyV6 []string) { + if p == nil { + return + } + for _, r := range p.Egress { + switch r.targetKind { + case targetIP: + addr := r.ip + target := addr.String() + if r.Action == ActionAllow { + if addr.Is4() { + allowV4 = append(allowV4, target) + } else if addr.Is6() { + allowV6 = append(allowV6, target) + } + } else { + if addr.Is4() { + denyV4 = append(denyV4, target) + } else if addr.Is6() { + denyV6 = append(denyV6, target) + } + } + case targetCIDR: + pfx := r.prefix + target := pfx.String() + if r.Action == ActionAllow { + if pfx.Addr().Is4() { + allowV4 = append(allowV4, target) + } else if pfx.Addr().Is6() { + allowV6 = append(allowV6, target) + } + } else { + if pfx.Addr().Is4() { + denyV4 = append(denyV4, target) + } else if pfx.Addr().Is6() { + denyV6 = append(denyV6, target) + } + } + default: + continue + } + } + return +} + +func (r *EgressRule) matchesDomain(domain string) bool { + pattern := strings.ToLower(strings.TrimSpace(r.Target)) + domain = strings.ToLower(domain) + + if pattern == "" { + return false + } + if pattern == domain { + return true + } + if strings.HasPrefix(pattern, "*.") { + // "*.example.com" matches "a.example.com" but not "example.com" + suffix := strings.TrimPrefix(pattern, "*") + return strings.HasSuffix(domain, suffix) && domain != strings.TrimPrefix(pattern, "*.") + } + return false +} diff --git a/components/egress/pkg/policy/policy_test.go b/components/egress/pkg/policy/policy_test.go new file mode 100644 index 0000000..92673d1 --- /dev/null +++ b/components/egress/pkg/policy/policy_test.go @@ -0,0 +1,170 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "net/netip" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParsePolicy_EmptyOrNullDefaultsDeny(t *testing.T) { + cases := []string{ + "", + " ", + "null", + "{}\n", + } + for _, raw := range cases { + p, err := ParsePolicy(raw) + require.NoErrorf(t, err, "raw %q returned error", raw) + require.NotNilf(t, p, "raw %q expected default deny policy, got nil", raw) + require.Equalf(t, ActionDeny, p.DefaultAction, "raw %q expected defaultAction deny", raw) + require.Equalf(t, ActionDeny, p.Evaluate("example.com."), "raw %q expected deny evaluation", raw) + } +} + +func TestParsePolicy_DefaultActionFallback(t *testing.T) { + p, err := ParsePolicy(`{"egress":[{"action":"allow","target":"example.com"}]}`) + require.NoError(t, err) + require.NotNil(t, p, "expected policy object, got nil") + require.Equal(t, ActionDeny, p.DefaultAction, "expected defaultAction fallback to deny") +} + +func TestParsePolicy_EmptyEgressDefaultsDeny(t *testing.T) { + p, err := ParsePolicy(`{"defaultAction":""}`) + require.NoError(t, err) + require.Equal(t, ActionDeny, p.DefaultAction, "expected default deny when defaultAction missing") + require.Equal(t, ActionDeny, p.Evaluate("anything.com."), "expected evaluation deny for empty egress") +} + +func TestParsePolicy_IPAndCIDRSupported(t *testing.T) { + raw := `{ + "defaultAction":"deny", + "egress":[ + {"action":"allow","target":"1.1.1.1"}, + {"action":"allow","target":"2.2.0.0/16"}, + {"action":"deny","target":"2001:db8::/32"}, + {"action":"deny","target":"2001:db8::1"} + ] + }` + p, err := ParsePolicy(raw) + require.NoError(t, err) + allowV4, allowV6, denyV4, denyV6 := p.StaticIPSets() + require.Len(t, allowV4, 2, "allowV4 length mismatch") + require.Equal(t, "1.1.1.1", allowV4[0]) + require.Equal(t, "2.2.0.0/16", allowV4[1]) + require.Len(t, denyV6, 2, "expected 2 denyV6 entries") + require.Empty(t, allowV6, "allowV6 should be empty") + require.Empty(t, denyV4, "denyV4 should be empty") +} + +func TestParsePolicy_InvalidAction(t *testing.T) { + _, err := ParsePolicy(`{"egress":[{"action":"foo","target":"example.com"}]}`) + require.Error(t, err, "expected error for invalid action") +} + +func TestParsePolicy_EmptyTargetError(t *testing.T) { + _, err := ParsePolicy(`{"egress":[{"action":"allow","target":""}]}`) + require.Error(t, err, "expected error for empty target") +} + +func TestWithExtraAllowIPs(t *testing.T) { + p, err := ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"example.com"}]}`) + require.NoError(t, err) + allowV4, allowV6, _, _ := p.StaticIPSets() + require.Empty(t, allowV4, "domain-only policy should have no static allowV4 IPs") + require.Empty(t, allowV6, "domain-only policy should have no static allowV6 IPs") + + ips := []netip.Addr{ + netip.MustParseAddr("192.168.65.7"), + netip.MustParseAddr("2001:db8::1"), + } + merged := p.WithExtraAllowIPs(ips) + require.NotSame(t, p, merged, "expected new policy instance") + allowV4, allowV6, _, _ = merged.StaticIPSets() + require.Len(t, allowV4, 1, "allowV4 length mismatch") + require.Equal(t, "192.168.65.7", allowV4[0]) + require.Len(t, allowV6, 1, "allowV6 length mismatch") + require.Equal(t, "2001:db8::1", allowV6[0]) + + // nil/empty ips returns same policy + require.Same(t, p, p.WithExtraAllowIPs(nil), "WithExtraAllowIPs(nil) should return same policy") + require.Same(t, p, p.WithExtraAllowIPs([]netip.Addr{}), "WithExtraAllowIPs([]) should return same policy") +} + +func TestEvaluate_CompiledIndexMatchesLinear(t *testing.T) { + p, err := ParsePolicy(`{ + "defaultAction":"deny", + "egress":[ + {"action":"allow","target":"*.example.com"}, + {"action":"deny","target":"api.example.com"}, + {"action":"allow","target":"*.internal.example.com"}, + {"action":"deny","target":"10.0.0.1"}, + {"action":"allow","target":"10.0.0.0/24"} + ] + }`) + require.NoError(t, err) + require.NotNil(t, p.domainIndex, "parsed policy should build compiled domain index") + + queries := []string{ + "api.example.com.", + "www.example.com.", + "a.internal.example.com.", + "internal.example.com.", + "unknown.test.", + } + for _, q := range queries { + got := p.Evaluate(q) + want, matched := p.evaluateLinear(normalizeQueryForTest(q)) + if !matched { + want = p.DefaultAction + } + require.Equalf(t, want, got, "compiled evaluate mismatch for query=%s", q) + } +} + +func TestEvaluate_ManualPolicyFallsBackToLinear(t *testing.T) { + manual := &NetworkPolicy{ + DefaultAction: ActionDeny, + Egress: []EgressRule{ + {Action: ActionAllow, Target: "*.example.com", targetKind: targetDomain}, + {Action: ActionDeny, Target: "api.example.com", targetKind: targetDomain}, + }, + } + require.Nil(t, manual.domainIndex, "manual policy intentionally skips compile") + require.Equal(t, ActionAllow, manual.Evaluate("api.example.com.")) + require.Equal(t, ActionAllow, manual.Evaluate("www.example.com.")) + require.Equal(t, ActionDeny, manual.Evaluate("unknown.example.")) +} + +func TestEvaluate_CompiledIndexKeepsFirstMatchPriority(t *testing.T) { + p := &NetworkPolicy{ + DefaultAction: ActionDeny, + Egress: []EgressRule{ + {Action: ActionAllow, Target: "*.example.com", targetKind: targetDomain}, + {Action: ActionDeny, Target: "api.example.com", targetKind: targetDomain}, + }, + } + p = ensureDefaults(p) + require.NotNil(t, p.domainIndex) + require.Equal(t, ActionAllow, p.Evaluate("api.example.com.")) +} + +func normalizeQueryForTest(domain string) string { + return strings.ToLower(strings.TrimSuffix(domain, ".")) +} diff --git a/components/egress/pkg/policy/rules_loader.go b/components/egress/pkg/policy/rules_loader.go new file mode 100644 index 0000000..6dd5505 --- /dev/null +++ b/components/egress/pkg/policy/rules_loader.go @@ -0,0 +1,131 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package policy + +import ( + "os" + "sync" + "time" +) + +type alwaysRuleFileState struct { + path string + action string + exists bool + modTime time.Time + size int64 + rules []EgressRule +} + +// AlwaysRuleLoader polls the standard always-deny/allow file paths at most once per refreshInterval. +type AlwaysRuleLoader struct { + mu sync.RWMutex + refreshInterval time.Duration + lastCheck time.Time + denyState alwaysRuleFileState + allowState alwaysRuleFileState +} + +func NewAlwaysRuleLoader(refreshInterval time.Duration) *AlwaysRuleLoader { + return newAlwaysRuleLoader(refreshInterval, alwaysDenyFilePath, alwaysAllowFilePath) +} + +func newAlwaysRuleLoader(refreshInterval time.Duration, denyPath, allowPath string) *AlwaysRuleLoader { + if refreshInterval <= 0 { + refreshInterval = time.Minute + } + return &AlwaysRuleLoader{ + refreshInterval: refreshInterval, + denyState: alwaysRuleFileState{path: denyPath, action: ActionDeny}, + allowState: alwaysRuleFileState{path: allowPath, action: ActionAllow}, + } +} + +// RefreshIfDue reloads from disk when the interval elapsed; changed is true only if file content actually differed. +func (l *AlwaysRuleLoader) RefreshIfDue(now time.Time) (deny, allow []EgressRule, changed bool, err error) { + l.mu.Lock() + defer l.mu.Unlock() + + if !l.lastCheck.IsZero() && now.Sub(l.lastCheck) < l.refreshInterval { + return cloneRules(l.denyState.rules), cloneRules(l.allowState.rules), false, nil + } + l.lastCheck = now + + denyChanged, err := l.refreshOne(&l.denyState) + if err != nil { + return nil, nil, false, err + } + allowChanged, err := l.refreshOne(&l.allowState) + if err != nil { + return nil, nil, false, err + } + changed = denyChanged || allowChanged + return cloneRules(l.denyState.rules), cloneRules(l.allowState.rules), changed, nil +} + +func (l *AlwaysRuleLoader) CurrentRules() (deny, allow []EgressRule) { + l.mu.RLock() + defer l.mu.RUnlock() + + return cloneRules(l.denyState.rules), cloneRules(l.allowState.rules) +} + +func (l *AlwaysRuleLoader) SetCurrentRules(deny, allow []EgressRule) { + l.mu.Lock() + defer l.mu.Unlock() + + l.denyState.rules = cloneRules(deny) + l.allowState.rules = cloneRules(allow) +} + +func (l *AlwaysRuleLoader) refreshOne(state *alwaysRuleFileState) (bool, error) { + info, err := os.Stat(state.path) + if err != nil { + if os.IsNotExist(err) { + if !state.exists { + return false, nil + } + state.exists = false + state.modTime = time.Time{} + state.size = 0 + state.rules = nil + return true, nil + } + return false, err + } + + if state.exists && info.ModTime().Equal(state.modTime) && info.Size() == state.size { + return false, nil + } + + rules, err := loadAlwaysRuleFile(state.path, state.action) + if err != nil { + return false, err + } + state.exists = true + state.modTime = info.ModTime() + state.size = info.Size() + state.rules = rules + return true, nil +} + +func cloneRules(in []EgressRule) []EgressRule { + if len(in) == 0 { + return nil + } + out := make([]EgressRule, len(in)) + copy(out, in) + return out +} diff --git a/components/egress/pkg/startup/hook.go b/components/egress/pkg/startup/hook.go new file mode 100644 index 0000000..da905a1 --- /dev/null +++ b/components/egress/pkg/startup/hook.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package startup + +import ( + "context" + "fmt" + "sync" +) + +type Hook interface { + Name() string + Run(ctx context.Context) error +} + +var ( + mu sync.Mutex + hooks []Hook +) + +func Register(h Hook) { + if h == nil { + return + } + mu.Lock() + defer mu.Unlock() + hooks = append(hooks, h) +} + +func RegisterFunc(name string, fn func(ctx context.Context) error) { + if fn == nil { + return + } + Register(funcHook{name: name, fn: fn}) +} + +type funcHook struct { + name string + fn func(context.Context) error +} + +func (f funcHook) Name() string { return f.name } + +func (f funcHook) Run(ctx context.Context) error { return f.fn(ctx) } + +func RunPost(ctx context.Context) error { + mu.Lock() + list := append([]Hook(nil), hooks...) + mu.Unlock() + for _, h := range list { + if err := h.Run(ctx); err != nil { + return fmt.Errorf("startup hook %q (post): %w", h.Name(), err) + } + } + return nil +} + +func resetHooksForTest() { + mu.Lock() + defer mu.Unlock() + hooks = nil +} diff --git a/components/egress/pkg/startup/hook_test.go b/components/egress/pkg/startup/hook_test.go new file mode 100644 index 0000000..60451fe --- /dev/null +++ b/components/egress/pkg/startup/hook_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package startup + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +type namedHook struct { + name string + run func(context.Context) error +} + +func (n namedHook) Name() string { return n.name } +func (n namedHook) Run(ctx context.Context) error { return n.run(ctx) } + +func TestRunPost_empty(t *testing.T) { + resetHooksForTest() + t.Cleanup(resetHooksForTest) + require.NoError(t, RunPost(context.Background())) +} + +func TestRunPost_orderAndError(t *testing.T) { + resetHooksForTest() + t.Cleanup(resetHooksForTest) + + var n int + Register(namedHook{ + name: "a", + run: func(context.Context) error { + n++ + require.Equal(t, 1, n) + return nil + }, + }) + Register(namedHook{ + name: "b", + run: func(context.Context) error { + n++ + require.Equal(t, 2, n) + return errors.New("boom") + }, + }) + Register(namedHook{ + name: "c", + run: func(context.Context) error { + n++ + return nil + }, + }) + + err := RunPost(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "b") + require.Contains(t, err.Error(), "boom") + require.Equal(t, 2, n) +} diff --git a/components/egress/pkg/telemetry/hostmetrics_linux.go b/components/egress/pkg/telemetry/hostmetrics_linux.go new file mode 100644 index 0000000..0db5600 --- /dev/null +++ b/components/egress/pkg/telemetry/hostmetrics_linux.go @@ -0,0 +1,38 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package telemetry + +import ( + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/mem" +) + +func systemMemoryUsedBytes() int64 { + stats, err := mem.VirtualMemory() + if err != nil { + return 0 + } + return int64(stats.Used) +} + +func cpuUtilizationRatio() float64 { + usage, err := cpu.Percent(0, false) + if err != nil || len(usage) == 0 { + return 0 + } + return usage[0] / 100.0 +} diff --git a/components/egress/pkg/telemetry/hostmetrics_stub.go b/components/egress/pkg/telemetry/hostmetrics_stub.go new file mode 100644 index 0000000..a2c32fe --- /dev/null +++ b/components/egress/pkg/telemetry/hostmetrics_stub.go @@ -0,0 +1,21 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package telemetry + +func systemMemoryUsedBytes() int64 { return 0 } + +func cpuUtilizationRatio() float64 { return 0 } diff --git a/components/egress/pkg/telemetry/init.go b/components/egress/pkg/telemetry/init.go new file mode 100644 index 0000000..9c7039d --- /dev/null +++ b/components/egress/pkg/telemetry/init.go @@ -0,0 +1,41 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + "os" + "strings" + + "go.opentelemetry.io/otel/attribute" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + inttelemetry "github.com/alibaba/opensandbox/internal/telemetry" + "github.com/alibaba/opensandbox/internal/version" +) + +const serviceName = "opensandbox-egress" + +func Init(ctx context.Context) (shutdown func(context.Context) error, err error) { + var attrs []attribute.KeyValue + if id := strings.TrimSpace(os.Getenv(constants.EnvSandboxID)); id != "" { + attrs = append(attrs, attribute.String("sandbox_id", id)) + } + return inttelemetry.Init(ctx, inttelemetry.Config{ + ServiceName: serviceName + "-" + version.Version, + ResourceAttributes: attrs, + RegisterMetrics: registerEgressMetrics, + }) +} diff --git a/components/egress/pkg/telemetry/meminfo_parse.go b/components/egress/pkg/telemetry/meminfo_parse.go new file mode 100644 index 0000000..e448c78 --- /dev/null +++ b/components/egress/pkg/telemetry/meminfo_parse.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "strconv" + "strings" +) + +// meminfoUsedBytesFromContent approximates used RAM from kB fields (MemTotal−MemAvailable preferred). +func meminfoUsedBytesFromContent(data []byte) int64 { + var memTotal, memAvail, memFree int64 + var haveT, haveA, haveF bool + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "MemTotal:"): + memTotal = meminfoFieldKB(line) + haveT = memTotal > 0 + case strings.HasPrefix(line, "MemAvailable:"): + memAvail = meminfoFieldKB(line) + haveA = true + case strings.HasPrefix(line, "MemFree:"): + memFree = meminfoFieldKB(line) + haveF = true + } + } + if haveT && haveA && memTotal >= memAvail { + return (memTotal - memAvail) * 1024 + } + if haveT && haveF && memTotal >= memFree { + return (memTotal - memFree) * 1024 + } + return 0 +} + +func meminfoFieldKB(line string) int64 { + fields := strings.Fields(line) + if len(fields) < 2 { + return 0 + } + v, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return 0 + } + return v +} diff --git a/components/egress/pkg/telemetry/meminfo_parse_test.go b/components/egress/pkg/telemetry/meminfo_parse_test.go new file mode 100644 index 0000000..5b53fa3 --- /dev/null +++ b/components/egress/pkg/telemetry/meminfo_parse_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMeminfoUsedBytesFromContent(t *testing.T) { + const sample = `MemTotal: 8000000 kB +MemFree: 1000000 kB +MemAvailable: 5000000 kB +` + // (8000000 - 5000000) * 1024 + assert.Equal(t, int64(3000000*1024), meminfoUsedBytesFromContent([]byte(sample))) + + fallback := `MemTotal: 8000000 kB +MemFree: 1000000 kB +` + assert.Equal(t, int64(7000000*1024), meminfoUsedBytesFromContent([]byte(fallback))) +} diff --git a/components/egress/pkg/telemetry/metrics.go b/components/egress/pkg/telemetry/metrics.go new file mode 100644 index 0000000..aa585b5 --- /dev/null +++ b/components/egress/pkg/telemetry/metrics.go @@ -0,0 +1,166 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + "sync" + "sync/atomic" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/policy" + slogger "github.com/alibaba/opensandbox/internal/logger" + inttelemetry "github.com/alibaba/opensandbox/internal/telemetry" +) + +var ( + meter metric.Meter + + dnsQueryDur metric.Float64Histogram + policyDenied metric.Int64Counter + nftUpdates metric.Int64Counter + + lastNftRuleCount atomic.Int64 +) + +var egressSharedAttrs = sync.OnceValue(func() []attribute.KeyValue { + return inttelemetry.SharedAttrsFromEnv(inttelemetry.SharedAttrsEnvConfig{ + SandboxIDEnv: constants.EnvSandboxID, + ExtraAttrsEnv: constants.EnvEgressMetricsExtraAttrs, + SandboxAttr: "sandbox_id", + }) +}) + +var egressMetricOpt = sync.OnceValue(func() metric.MeasurementOption { + return metric.WithAttributes(egressSharedAttrs()...) +}) + +func EgressLogFields() []slogger.Field { + kvs := egressSharedAttrs() + out := make([]slogger.Field, 0, len(kvs)) + for _, kv := range kvs { + var v string + if kv.Value.Type() == attribute.STRING { + v = kv.Value.AsString() + } else { + v = kv.Value.Emit() + } + out = append(out, slogger.Field{Key: string(kv.Key), Value: v}) + } + return out +} + +func registerEgressMetrics() error { + meter = otel.Meter("opensandbox/egress") + + var err error + dnsQueryDur, err = meter.Float64Histogram( + "egress.dns.query.duration", + metric.WithDescription("DNS forward latency"), + metric.WithUnit("s"), + ) + if err != nil { + return err + } + policyDenied, err = meter.Int64Counter( + "egress.policy.denied_total", + metric.WithDescription("DNS policy denials"), + ) + if err != nil { + return err + } + nftUpdates, err = meter.Int64Counter( + "egress.nftables.updates.count", + metric.WithDescription("nft static apply and dynamic IP adds"), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "egress.nftables.rules.count", + metric.WithDescription("Approximate policy size after last static apply"), + metric.WithUnit("{element}"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + obs.Observe(lastNftRuleCount.Load(), egressMetricOpt()) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "egress.system.memory.usage_bytes", + metric.WithDescription("System RAM used bytes from gopsutil on Linux (non-Linux build: 0)."), + metric.WithUnit("By"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + obs.Observe(systemMemoryUsedBytes(), egressMetricOpt()) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Float64ObservableGauge( + "egress.system.cpu.utilization", + metric.WithDescription("CPU busy ratio 0-1 from gopsutil on Linux (non-Linux build: 0)."), + metric.WithUnit("1"), + metric.WithFloat64Callback(func(ctx context.Context, obs metric.Float64Observer) error { + obs.Observe(cpuUtilizationRatio(), egressMetricOpt()) + return nil + }), + ) + return err +} + +func NftRuleCountFromPolicy(p *policy.NetworkPolicy) int64 { + if p == nil { + p = policy.DefaultDenyPolicy() + } + a4, a6, d4, d6 := p.StaticIPSets() + return int64(len(p.Egress) + len(a4) + len(a6) + len(d4) + len(d6)) +} + +func RecordDNSForward(seconds float64) { + if dnsQueryDur == nil { + return + } + opt := egressMetricOpt() + dnsQueryDur.Record(context.Background(), seconds, opt) +} + +func RecordDNSDenied() { + if policyDenied == nil { + return + } + policyDenied.Add(context.Background(), 1, egressMetricOpt()) +} + +func SetNftablesRuleCount(n int64) { + lastNftRuleCount.Store(n) +} + +func RecordNftablesUpdate() { + if nftUpdates == nil { + return + } + nftUpdates.Add(context.Background(), 1, egressMetricOpt()) +} diff --git a/components/egress/pkg/telemetry/metrics_test.go b/components/egress/pkg/telemetry/metrics_test.go new file mode 100644 index 0000000..a757c45 --- /dev/null +++ b/components/egress/pkg/telemetry/metrics_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/attribute" + + inttelemetry "github.com/alibaba/opensandbox/internal/telemetry" +) + +func TestAppendMetricAttrsFromKeyValuePairs(t *testing.T) { + var base []attribute.KeyValue + out := inttelemetry.AppendAttrsFromKeyValuePairs(base, "a=b") + assert.Len(t, out, 1) + assert.Equal(t, "a", string(out[0].Key)) + assert.Equal(t, "b", out[0].Value.AsString()) + + out = inttelemetry.AppendAttrsFromKeyValuePairs(nil, " foo=bar , baz=qux ") + assert.Len(t, out, 2) + assert.Equal(t, "foo", string(out[0].Key)) + assert.Equal(t, "bar", out[0].Value.AsString()) + assert.Equal(t, "baz", string(out[1].Key)) + assert.Equal(t, "qux", out[1].Value.AsString()) + + out = inttelemetry.AppendAttrsFromKeyValuePairs(nil, "k=v=x") + assert.Len(t, out, 1) + assert.Equal(t, "k", string(out[0].Key)) + assert.Equal(t, "v=x", out[0].Value.AsString()) + + out = inttelemetry.AppendAttrsFromKeyValuePairs(nil, "novalue=,=bad,nokv") + assert.Len(t, out, 0) +} diff --git a/components/egress/pkg/telemetry/procstat_parse.go b/components/egress/pkg/telemetry/procstat_parse.go new file mode 100644 index 0000000..5062208 --- /dev/null +++ b/components/egress/pkg/telemetry/procstat_parse.go @@ -0,0 +1,45 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "strconv" + "strings" +) + +// procStatCPUJiffies parses the aggregate "cpu" line; idle counts idle+iowait (fields 4–5). +func procStatCPUJiffies(line string) (total, idle uint64, ok bool) { + fields := strings.Fields(line) + if len(fields) < 5 || fields[0] != "cpu" { + return 0, 0, false + } + var nums []uint64 + for i := 1; i < len(fields); i++ { + v, err := strconv.ParseUint(fields[i], 10, 64) + if err != nil { + return 0, 0, false + } + nums = append(nums, v) + } + var sum uint64 + for _, v := range nums { + sum += v + } + idle = nums[3] + if len(nums) > 4 { + idle += nums[4] + } + return sum, idle, true +} diff --git a/components/egress/pkg/telemetry/procstat_parse_test.go b/components/egress/pkg/telemetry/procstat_parse_test.go new file mode 100644 index 0000000..2f8ddd7 --- /dev/null +++ b/components/egress/pkg/telemetry/procstat_parse_test.go @@ -0,0 +1,30 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProcStatCPUJiffies(t *testing.T) { + // synthetic: user nice system idle iowait + line := "cpu 100 0 50 200 25 0 0 0 0 0" + total, idle, ok := procStatCPUJiffies(line) + assert.True(t, ok) + assert.Equal(t, uint64(375), total) + assert.Equal(t, uint64(225), idle) // 200+25 +} diff --git a/components/egress/policy_server.go b/components/egress/policy_server.go new file mode 100644 index 0000000..783e693 --- /dev/null +++ b/components/egress/policy_server.go @@ -0,0 +1,826 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "net" + "net/http" + "net/netip" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/credentialvault" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/mitmproxy" + "github.com/alibaba/opensandbox/egress/pkg/nftables" + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/alibaba/opensandbox/internal/safego" + "k8s.io/apimachinery/pkg/util/wait" +) + +type policyUpdater interface { + CurrentPolicy() *policy.NetworkPolicy + UpdatePolicy(*policy.NetworkPolicy) + UpdateAlwaysRules(alwaysDeny, alwaysAllow []policy.EgressRule) +} + +// nftApplier: static allow/deny sets plus dynamic DNS-learned entries; teardown on shutdown. +type nftApplier interface { + ApplyStatic(context.Context, *policy.NetworkPolicy) error + AddResolvedIPs(context.Context, []nftables.ResolvedIP) error + RemoveEnforcement(context.Context) error +} + +// startPolicyServer: runtime POST/GET /policy, GET /healthz. nameserverIPs are merged into every nft +// static apply so the pod’s resolv / private DNS still works alongside user egress rules. +func startPolicyServer( + proxy policyUpdater, + nft nftApplier, + enforcementMode string, + addr string, + token string, + nameserverIPs []netip.Addr, + policyFile string, + alwaysDeny, alwaysAllow []policy.EgressRule, + mitmGate *mitmproxy.HealthGate, +) (*http.Server, error) { + maxEgressRules := maxEgressRulesFromEnv() + if maxEgressRules > 0 { + log.Infof("policy API: max egress rules per policy (POST/PATCH) = %d (set %s=0 to disable)", maxEgressRules, constants.EnvMaxEgressRules) + } + + mux := http.NewServeMux() + handler := &policyServer{ + proxy: proxy, + nft: nft, + token: token, + enforcementMode: enforcementMode, + nameserverIPs: nameserverIPs, + policyFile: strings.TrimSpace(policyFile), + maxEgressRules: maxEgressRules, + alwaysLoader: policy.NewAlwaysRuleLoader(time.Minute), + stopAlwaysReload: make(chan struct{}), + mitmGate: mitmGate, + } + handler.credentialVault = credentialvault.NewStore(mitmGate, func() bool { return strings.TrimSpace(token) != "" }) + handler.credentialVaultRequireTLS = constants.IsTruthy(os.Getenv(constants.EnvCredentialVaultRequireTLS)) + handler.setAlwaysRules(alwaysDeny, alwaysAllow) + + mux.HandleFunc("/policy", handler.handlePolicy) + mux.HandleFunc("/credential-vault", handler.handleCredentialVault) + mux.HandleFunc("/credential-vault/", handler.handleCredentialVaultSubresource) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + if mitmGate != nil && mitmGate.MitmPending() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("mitmproxy not ready\n")) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + var activeSrv *http.Server + var cleanupActiveSocket func(context.Context) error + if constants.IsTruthy(os.Getenv(constants.EnvMitmproxyTransparent)) { + socketPath := envOrDefault(constants.EnvCredentialProxySocket, constants.DefaultCredentialProxySocket) + _, mitmGID, _, err := mitmproxy.LookupUser(mitmproxy.RunAsUser) + if err != nil { + return nil, fmt.Errorf("lookup credential proxy user %q: %w", mitmproxy.RunAsUser, err) + } + activeSrv, cleanupActiveSocket, err = credentialvault.StartActiveSocketServer(handler.handleCredentialVaultActive, socketPath, int(mitmGID)) + if err != nil { + return nil, fmt.Errorf("credential vault active socket: %w", err) + } + log.Infof("credential vault active API listening on unix socket %s", socketPath) + } + + srv := &http.Server{Addr: addr, Handler: mux} + handler.server = srv + srv.RegisterOnShutdown(func() { + select { + case <-handler.stopAlwaysReload: + default: + close(handler.stopAlwaysReload) + } + if activeSrv != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := cleanupActiveSocket(shutdownCtx); err != nil { + log.Errorf("credential vault active socket shutdown error: %v", err) + } + } + }) + + errCh := make(chan error, 1) + safego.Go(func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }) + + select { + case err := <-errCh: + if activeSrv != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + if cleanupErr := cleanupActiveSocket(shutdownCtx); cleanupErr != nil { + log.Errorf("credential vault active socket shutdown error: %v", cleanupErr) + } + cancel() + } + return nil, err + case <-time.After(200 * time.Millisecond): + handler.startAlwaysRuleReloadJob() + safego.Go(func() { + if err := <-errCh; err != nil { + log.Errorf("policy server error: %v", err) + } + }) + return srv, nil + } +} + +type policyServer struct { + proxy policyUpdater + nft nftApplier + server *http.Server + token string + enforcementMode string + nameserverIPs []netip.Addr + policyFile string // if set, successful /policy changes persist (truncate+write+fsync) + maxEgressRules int // 0 = unlimited; cap len(Egress) for POST/PATCH + mu sync.Mutex // serializes /policy handlers (no lost update across POST vs PATCH) + + alwaysLoader *policy.AlwaysRuleLoader + stopAlwaysReload chan struct{} + + lastAlwaysFP uint64 + lastAlwaysFPSet bool + credentialVault *credentialvault.Store + mitmGate *mitmproxy.HealthGate + credentialVaultRequireTLS bool +} + +type policyStatusResponse struct { + Status string `json:"status,omitempty"` + Mode string `json:"mode,omitempty"` + EnforcementMode string `json:"enforcementMode,omitempty"` + Reason string `json:"reason,omitempty"` + Policy any `json:"policy,omitempty"` +} + +func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) { + if !s.authorize(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + switch r.Method { + case http.MethodGet: + s.handleGet(w) + case http.MethodPost, http.MethodPut: + s.handlePost(w, r) + case http.MethodPatch: + s.handlePatch(w, r) + case http.MethodDelete: + s.handleDelete(w, r) + default: + w.Header().Set("Allow", "GET, POST, PUT, PATCH, DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *policyServer) handleCredentialVault(w http.ResponseWriter, r *http.Request) { + if !s.authorize(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + switch r.Method { + case http.MethodGet: + s.handleCredentialVaultGet(w) + case http.MethodPost: + s.handleCredentialVaultPost(w, r) + case http.MethodPatch: + s.handleCredentialVaultPatch(w, r) + case http.MethodDelete: + s.handleCredentialVaultDelete(w, r) + default: + w.Header().Set("Allow", "GET, POST, PATCH, DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *policyServer) handleCredentialVaultSubresource(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/credential-vault/") + switch { + case path == "_active": + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + if !s.authorize(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + switch { + case path == "credentials": + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleCredentialVaultCredentials(w) + case strings.HasPrefix(path, "credentials/"): + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleCredentialVaultCredential(w, strings.TrimPrefix(path, "credentials/")) + case path == "bindings": + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleCredentialVaultBindings(w) + case strings.HasPrefix(path, "bindings/"): + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.handleCredentialVaultBinding(w, strings.TrimPrefix(path, "bindings/")) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func (s *policyServer) handleCredentialVaultGet(w http.ResponseWriter) { + state, err := s.credentialVault.Sanitized() + if err != nil { + credentialvault.WriteError(w, err) + return + } + writeJSON(w, http.StatusOK, state) +} + +func (s *policyServer) handleCredentialVaultPost(w http.ResponseWriter, r *http.Request) { + if err := s.credentialVault.Ready(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusPreconditionFailed) + return + } + if s.credentialVaultRequireTLS && !credentialVaultWriteTransportAllowed(r) { + http.Error(w, "credential vault writes require TLS or loopback transport", http.StatusUpgradeRequired) + return + } + var req credentialvault.CreateRequest + if err := credentialvault.ReadJSON(r, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid credential vault request: %v", err), http.StatusBadRequest) + return + } + state, err := s.credentialVault.Create(req, s.effectivePolicy()) + if err != nil { + credentialvault.WriteError(w, err) + return + } + writeJSON(w, http.StatusCreated, state) +} + +func (s *policyServer) handleCredentialVaultPatch(w http.ResponseWriter, r *http.Request) { + if err := s.credentialVault.Ready(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusPreconditionFailed) + return + } + if s.credentialVaultRequireTLS && !credentialVaultWriteTransportAllowed(r) { + http.Error(w, "credential vault writes require TLS or loopback transport", http.StatusUpgradeRequired) + return + } + var req credentialvault.MutationRequest + if err := credentialvault.ReadJSON(r, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid credential vault mutation request: %v", err), http.StatusBadRequest) + return + } + state, err := s.credentialVault.Patch(req, s.effectivePolicy()) + if err != nil { + credentialvault.WriteError(w, err) + return + } + writeJSON(w, http.StatusOK, state) +} + +func (s *policyServer) handleCredentialVaultDelete(w http.ResponseWriter, r *http.Request) { + if err := s.credentialVault.Ready(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusPreconditionFailed) + return + } + if s.credentialVaultRequireTLS && !credentialVaultWriteTransportAllowed(r) { + http.Error(w, "credential vault writes require TLS or loopback transport", http.StatusUpgradeRequired) + return + } + if err := s.credentialVault.Delete(); err != nil { + credentialvault.WriteError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *policyServer) handleCredentialVaultCredentials(w http.ResponseWriter) { + state, err := s.credentialVault.Sanitized() + if err != nil { + credentialvault.WriteError(w, err) + return + } + writeJSON(w, http.StatusOK, credentialvault.ListResponse{Revision: state.Revision, Credentials: state.Credentials}) +} + +func (s *policyServer) handleCredentialVaultCredential(w http.ResponseWriter, name string) { + state, err := s.credentialVault.Sanitized() + if err != nil { + credentialvault.WriteError(w, err) + return + } + name = strings.TrimSpace(name) + for _, credential := range state.Credentials { + if credential.Name == name { + writeJSON(w, http.StatusOK, credential) + return + } + } + http.Error(w, "credential not found", http.StatusNotFound) +} + +func (s *policyServer) handleCredentialVaultBindings(w http.ResponseWriter) { + state, err := s.credentialVault.Sanitized() + if err != nil { + credentialvault.WriteError(w, err) + return + } + writeJSON(w, http.StatusOK, credentialvault.BindingListResponse{Revision: state.Revision, Bindings: state.Bindings}) +} + +func (s *policyServer) handleCredentialVaultBinding(w http.ResponseWriter, name string) { + state, err := s.credentialVault.Sanitized() + if err != nil { + credentialvault.WriteError(w, err) + return + } + name = strings.TrimSpace(name) + for _, binding := range state.Bindings { + if binding.Name == name { + writeJSON(w, http.StatusOK, binding) + return + } + } + http.Error(w, "binding not found", http.StatusNotFound) +} + +func (s *policyServer) handleCredentialVaultActive(w http.ResponseWriter) { + snapshot, err := s.credentialVault.ActiveSnapshot() + if err != nil { + credentialvault.WriteError(w, err) + return + } + writeJSON(w, http.StatusOK, snapshot) +} + +func (s *policyServer) handleGet(w http.ResponseWriter) { + current := s.proxy.CurrentPolicy() + mode := modeFromPolicy(current) + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + Policy: current, + }) +} + +func (s *policyServer) handlePost(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := readPolicyRequestBody(r) + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err)) + http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) + return + } + + if raw == "" { + log.Infof("policy API: reset to default deny-all") + def := policy.DefaultDenyPolicy() + if err := s.validateCredentialVaultPolicyUpdate(def); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("credential vault policy validation: %v", err)) + http.Error(w, fmt.Sprintf("credential vault policy validation: %v", err), http.StatusBadRequest) + return + } + if !s.commitPolicy(r.Context(), w, def, "reset") { + return + } + logEgressUpdated(def.DefaultAction, nil) + log.Infof("policy API: proxy and nftables updated to deny_all") + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: "deny_all", + Reason: "policy reset to default deny-all", + }) + return + } + + pol, err := policy.ParsePolicy(raw) + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("invalid policy: %v", err)) + http.Error(w, fmt.Sprintf("invalid policy: %v", err), http.StatusBadRequest) + return + } + if !s.enforceEgressRuleLimit(w, len(pol.Egress)) { + return + } + + mode := modeFromPolicy(pol) + log.Infof("policy API: updating policy to mode=%s, enforcement=%s", mode, s.enforcementMode) + if err := s.validateCredentialVaultPolicyUpdate(pol); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("credential vault policy validation: %v", err)) + http.Error(w, fmt.Sprintf("credential vault policy validation: %v", err), http.StatusBadRequest) + return + } + if !s.commitPolicy(r.Context(), w, pol, "post") { + return + } + logEgressUpdated(pol.DefaultAction, pol.Egress) + log.Infof("policy API: proxy and nftables updated successfully") + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + }) +} + +func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := readPolicyRequestBody(r) + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err)) + http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) + return + } + if raw == "" { + logEgressUpdateFailedWarn("empty patch body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + var patchRules []policy.EgressRule + if err := json.Unmarshal([]byte(raw), &patchRules); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("invalid patch rules: %v", err)) + http.Error(w, fmt.Sprintf("invalid patch rules: %v", err), http.StatusBadRequest) + return + } + if len(patchRules) == 0 { + logEgressUpdateFailedWarn("empty patch rules array") + http.Error(w, "invalid patch rules: empty array", http.StatusBadRequest) + return + } + + newPolicy, err := patchMergedPolicy(s.proxy.CurrentPolicy(), patchRules) + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("invalid merged policy: %v", err)) + http.Error(w, fmt.Sprintf("invalid merged policy: %v", err), http.StatusBadRequest) + return + } + if !s.enforceEgressRuleLimit(w, len(newPolicy.Egress)) { + return + } + + mode := modeFromPolicy(newPolicy) + log.Infof("policy API: patching policy with %d new rule(s), mode=%s, enforcement=%s", len(patchRules), mode, s.enforcementMode) + if err := s.validateCredentialVaultPolicyUpdate(newPolicy); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("credential vault policy validation: %v", err)) + http.Error(w, fmt.Sprintf("credential vault policy validation: %v", err), http.StatusBadRequest) + return + } + if !s.commitPolicy(r.Context(), w, newPolicy, "patch") { + return + } + logEgressUpdated(newPolicy.DefaultAction, patchRules) + log.Infof("policy API: patch applied successfully") + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + }) +} + +func (s *policyServer) handleDelete(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := readPolicyRequestBody(r) + if err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("failed to read body: %v", err)) + http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) + return + } + if raw == "" { + logEgressUpdateFailedWarn("empty delete body") + http.Error(w, "empty body", http.StatusBadRequest) + return + } + + var targets []string + if err := json.Unmarshal([]byte(raw), &targets); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("invalid delete targets: %v", err)) + http.Error(w, fmt.Sprintf("invalid delete targets: %v", err), http.StatusBadRequest) + return + } + if len(targets) == 0 { + logEgressUpdateFailedWarn("empty delete targets array") + http.Error(w, "invalid delete targets: empty array", http.StatusBadRequest) + return + } + + base := s.proxy.CurrentPolicy() + if base == nil { + base = policy.DefaultDenyPolicy() + } + oldCount := len(base.Egress) + newEgress, removedRules := removeRulesByTarget(base.Egress, targets) + removed := oldCount - len(newEgress) + + if removed == 0 { + mode := modeFromPolicy(base) + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + Reason: "no matching targets found", + }) + return + } + + rawMerged, err := json.Marshal(policy.NetworkPolicy{ + DefaultAction: base.DefaultAction, + Egress: newEgress, + }) + if err != nil { + logEgressUpdateFailedError(fmt.Sprintf("failed to marshal updated policy: %v", err)) + http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError) + return + } + newPolicy, err := policy.ParsePolicy(string(rawMerged)) + if err != nil { + logEgressUpdateFailedError(fmt.Sprintf("invalid policy after delete: %v", err)) + http.Error(w, fmt.Sprintf("internal error: %v", err), http.StatusInternalServerError) + return + } + + mode := modeFromPolicy(newPolicy) + log.Infof("policy API: deleting %d egress rule(s) by target, removed=%d, mode=%s, enforcement=%s", len(targets), removed, mode, s.enforcementMode) + if err := s.validateCredentialVaultPolicyUpdate(newPolicy); err != nil { + logEgressUpdateFailedWarn(fmt.Sprintf("credential vault policy validation: %v", err)) + http.Error(w, fmt.Sprintf("credential vault policy validation: %v", err), http.StatusBadRequest) + return + } + if !s.commitPolicy(r.Context(), w, newPolicy, "delete") { + return + } + logEgressUpdated(newPolicy.DefaultAction, removedRules) + log.Infof("policy API: delete applied successfully") + writeJSON(w, http.StatusOK, policyStatusResponse{ + Status: "ok", + Mode: mode, + EnforcementMode: s.enforcementMode, + }) +} + +// commitPolicy applies one logical change: optional disk persist → merge always file rules → nft +// static (with nameserver allow-IPs) → then update in-memory user policy (POST/PATCH/GET view). +func (s *policyServer) commitPolicy(ctx context.Context, w http.ResponseWriter, pol *policy.NetworkPolicy, op string) bool { + if err := s.persistPolicy(pol); err != nil { + logEgressUpdateFailedError(fmt.Sprintf("persist policy: %v", err)) + log.Errorf("policy API: persist policy failed: %v", err) + http.Error(w, fmt.Sprintf("failed to persist policy: %v", err), http.StatusInternalServerError) + return false + } + alwaysDeny, alwaysAllow := s.currentAlwaysRules() + merged := policy.MergeAlwaysOverlay(pol, alwaysDeny, alwaysAllow) + if s.nft != nil { + nftCtx, nftCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer nftCancel() + if err := s.nft.ApplyStatic(nftCtx, merged.WithExtraAllowIPs(s.nameserverIPs)); err != nil { + logEgressUpdateFailedError(fmt.Sprintf("nftables apply (%s): %v", op, err)) + log.Errorf("policy API: nftables apply failed (%s): %v", op, err) + http.Error(w, fmt.Sprintf("failed to apply nftables policy: %v", err), http.StatusInternalServerError) + return false + } + } + s.proxy.UpdatePolicy(pol) + return true +} + +func (s *policyServer) startAlwaysRuleReloadJob() { + safego.Go(func() { + wait.Until(s.reloadAlwaysRulesJob, time.Minute, s.stopAlwaysReload) + }) +} + +func (s *policyServer) reloadAlwaysRulesJob() { + changed, reloadErr := s.reloadAlwaysRules() + if reloadErr != nil { + log.Warnf("policy API: periodic reload of always rules failed: %v", reloadErr) + return + } + if !changed { + return + } + current := s.proxy.CurrentPolicy() + alwaysDeny, alwaysAllow := s.currentAlwaysRules() + merged := policy.MergeAlwaysOverlay(current, alwaysDeny, alwaysAllow) + if s.nft != nil { + if applyErr := s.nft.ApplyStatic(context.Background(), merged.WithExtraAllowIPs(s.nameserverIPs)); applyErr != nil { + log.Warnf("policy API: apply reloaded always rules to nftables failed: %v", applyErr) + return + } + } + fp := fingerprintRules(alwaysDeny, alwaysAllow) + if s.lastAlwaysFPSet && fp == s.lastAlwaysFP { + return + } + s.lastAlwaysFP = fp + s.lastAlwaysFPSet = true + log.Infof("policy API: reloaded always rules applied (deny=%d allow=%d fp=%016x)", len(alwaysDeny), len(alwaysAllow), fp) +} + +func fingerprintRules(deny, allow []policy.EgressRule) uint64 { + h := fnv.New64a() + writeSet := func(rs []policy.EgressRule) { + keys := make([]string, len(rs)) + for i, r := range rs { + keys[i] = r.Action + "|" + r.Target + } + sort.Strings(keys) + for _, k := range keys { + _, _ = h.Write([]byte(k)) + _, _ = h.Write([]byte{0}) + } + } + writeSet(deny) + _, _ = h.Write([]byte{0xff}) + writeSet(allow) + return h.Sum64() +} + +func (s *policyServer) reloadAlwaysRules() (bool, error) { + if s.alwaysLoader == nil { + return false, nil + } + deny, allow, changed, err := s.alwaysLoader.RefreshIfDue(time.Now()) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + s.proxy.UpdateAlwaysRules(deny, allow) + return true, nil +} + +func (s *policyServer) setAlwaysRules(deny, allow []policy.EgressRule) { + if s.alwaysLoader == nil { + s.alwaysLoader = policy.NewAlwaysRuleLoader(time.Minute) + } + s.alwaysLoader.SetCurrentRules(deny, allow) +} + +func (s *policyServer) currentAlwaysRules() (deny, allow []policy.EgressRule) { + if s.alwaysLoader == nil { + return nil, nil + } + return s.alwaysLoader.CurrentRules() +} + +func (s *policyServer) effectivePolicy() *policy.NetworkPolicy { + current := s.proxy.CurrentPolicy() + if current == nil { + current = policy.DefaultDenyPolicy() + } + alwaysDeny, alwaysAllow := s.currentAlwaysRules() + return policy.MergeAlwaysOverlay(current, alwaysDeny, alwaysAllow) +} + +func (s *policyServer) validateCredentialVaultPolicyUpdate(pol *policy.NetworkPolicy) error { + if s.credentialVault == nil { + return nil + } + alwaysDeny, alwaysAllow := s.currentAlwaysRules() + return s.credentialVault.ValidateActiveAgainstPolicy(policy.MergeAlwaysOverlay(pol, alwaysDeny, alwaysAllow)) +} + +func (s *policyServer) authorize(r *http.Request) bool { + if s.token == "" { + return true + } + provided := r.Header.Get(constants.EgressAuthTokenHeader) + if provided == "" { + return false + } + if len(provided) != len(s.token) { + return false + } + return subtle.ConstantTimeCompare([]byte(provided), []byte(s.token)) == 1 +} + +func credentialVaultWriteTransportAllowed(r *http.Request) bool { + if r.TLS != nil || isLoopbackRequest(r) { + return true + } + if !strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") { + return false + } + remoteIP := requestRemoteIP(r) + if !remoteIP.IsValid() { + return false + } + for _, raw := range strings.Split(os.Getenv(constants.EnvCredentialVaultTrustedProxyCIDRs), ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + prefix, err := netip.ParsePrefix(raw) + if err == nil && prefix.Contains(remoteIP) { + return true + } + addr, err := netip.ParseAddr(raw) + if err == nil && addr == remoteIP { + return true + } + } + return false +} + +func isLoopbackRequest(r *http.Request) bool { + ip := requestRemoteIP(r) + return ip.IsValid() && ip.IsLoopback() +} + +func requestRemoteIP(r *http.Request) netip.Addr { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip, err := netip.ParseAddr(strings.TrimSpace(host)) + if err != nil { + return netip.Addr{} + } + return ip.Unmap() +} + +func (s *policyServer) enforceEgressRuleLimit(w http.ResponseWriter, egressCount int) bool { + if s.maxEgressRules <= 0 { + return true + } + if egressCount > s.maxEgressRules { + logEgressUpdateFailedWarn(fmt.Sprintf("egress rule total count %d exceeds limit %d", egressCount, s.maxEgressRules)) + http.Error(w, fmt.Sprintf("egress rule total count %d exceeds limit %d", egressCount, s.maxEgressRules), http.StatusRequestEntityTooLarge) + return false + } + return true +} + +func (s *policyServer) persistPolicy(p *policy.NetworkPolicy) error { + if s.policyFile == "" { + return nil + } + return policy.SavePolicyFile(s.policyFile, p) +} diff --git a/components/egress/policy_server_test.go b/components/egress/policy_server_test.go new file mode 100644 index 0000000..50832e9 --- /dev/null +++ b/components/egress/policy_server_test.go @@ -0,0 +1,462 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/nftables" + "github.com/alibaba/opensandbox/egress/pkg/policy" + "github.com/stretchr/testify/require" +) + +type stubProxy struct { + updated *policy.NetworkPolicy + deny []policy.EgressRule + allow []policy.EgressRule +} + +func (s *stubProxy) CurrentPolicy() *policy.NetworkPolicy { + return s.updated +} + +func (s *stubProxy) UpdatePolicy(p *policy.NetworkPolicy) { + s.updated = p +} + +func (s *stubProxy) UpdateAlwaysRules(alwaysDeny, alwaysAllow []policy.EgressRule) { + s.deny = append([]policy.EgressRule(nil), alwaysDeny...) + s.allow = append([]policy.EgressRule(nil), alwaysAllow...) +} + +type stubNft struct { + err error + calls int + applied *policy.NetworkPolicy +} + +func (s *stubNft) ApplyStatic(_ context.Context, p *policy.NetworkPolicy) error { + s.calls++ + s.applied = p + return s.err +} + +func (s *stubNft) AddResolvedIPs(_ context.Context, _ []nftables.ResolvedIP) error { + return nil +} + +func (s *stubNft) RemoveEnforcement(_ context.Context) error { + return nil +} + +func TestHandlePolicy_AlwaysDenyMergedIntoNft(t *testing.T) { + deny, err := policy.ParseValidatedEgressRule(policy.ActionDeny, "9.9.9.9") + require.NoError(t, err) + proxy := &stubProxy{} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + srv.setAlwaysRules([]policy.EgressRule{deny}, nil) + + body := `{"defaultAction":"deny","egress":[{"action":"allow","target":"9.9.9.9"}]}` + req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") + require.NotNil(t, nft.applied, "expected nft applied") + _, _, denyV4, _ := nft.applied.StaticIPSets() + require.Contains(t, denyV4, "9.9.9.9", "always deny must appear in nft static deny set") + require.Len(t, proxy.updated.Egress, 1, "persisted/user policy must not include always rules") + require.Equal(t, "9.9.9.9", proxy.updated.Egress[0].Target) +} + +func TestHandlePolicy_AppliesNftAndUpdatesProxy(t *testing.T) { + proxy := &stubProxy{} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"}]}` + req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") + require.Contains(t, resp.Header.Get("Content-Type"), "application/json", "expected json response") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.NotNil(t, proxy.updated, "expected proxy policy to be updated") + require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "unexpected defaultAction") +} + +func TestHandlePolicy_NftFailureReturns500(t *testing.T) { + proxy := &stubProxy{} + nft := &stubNft{err: errors.New("boom")} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"}]}` + req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected 500") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.Nil(t, proxy.updated, "expected proxy policy not updated on nft failure") +} + +func TestHandleGet_ReturnsEnforcementMode(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + req := httptest.NewRequest(http.MethodGet, "/policy", nil) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200") + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), `"enforcementMode":"dns"`, "expected enforcementMode dns in response") +} + +func TestHandlePatch_MergesAndApplies(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "example.com"}, + {Action: policy.ActionDeny, Target: "*.example.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `[{"action":"deny","target":"blocked.com"},{"action":"allow","target":"example.com"}]` + req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.NotNil(t, proxy.updated, "expected proxy policy to be updated") + require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "default action should be preserved") + require.Len(t, proxy.updated.Egress, 3, "expected 3 egress rules") + require.Equal(t, policy.ActionDeny, proxy.updated.Egress[0].Action, "first rule action mismatch") + require.Equal(t, "blocked.com", proxy.updated.Egress[0].Target, "first rule target mismatch") + require.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action, "second rule action mismatch") + require.Equal(t, "example.com", proxy.updated.Egress[1].Target, "second rule target mismatch") + require.Equal(t, policy.ActionDeny, proxy.updated.Egress[2].Action, "base wildcard rule action mismatch") + require.Equal(t, "*.example.com", proxy.updated.Egress[2].Target, "base wildcard rule target mismatch") +} + +func TestHandlePatch_DomainCaseOverride(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionDeny, Target: "Example.COM"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `[{"action":"allow","target":"example.com"}]` + req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200") + require.NotNil(t, proxy.updated, "expected proxy policy to be updated") + require.Len(t, proxy.updated.Egress, 1, "expected deduped rule count 1") + require.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action, "expected allow action") + require.Equal(t, "example.com", proxy.updated.Egress[0].Target, "expected allow example.com to override") +} + +func TestMaxEgressRulesFromEnv(t *testing.T) { + old := os.Getenv(constants.EnvMaxEgressRules) + defer func() { _ = os.Setenv(constants.EnvMaxEgressRules, old) }() + + require.NoError(t, os.Unsetenv(constants.EnvMaxEgressRules)) + require.Equal(t, constants.DefaultMaxEgressRules, maxEgressRulesFromEnv(), "empty env uses default") + + require.NoError(t, os.Setenv(constants.EnvMaxEgressRules, "0")) + require.Equal(t, 0, maxEgressRulesFromEnv(), "0 means unlimited") + + require.NoError(t, os.Setenv(constants.EnvMaxEgressRules, "100")) + require.Equal(t, 100, maxEgressRulesFromEnv()) + + require.NoError(t, os.Setenv(constants.EnvMaxEgressRules, "not-a-number")) + require.Equal(t, constants.DefaultMaxEgressRules, maxEgressRulesFromEnv(), "invalid falls back to default") + + require.NoError(t, os.Setenv(constants.EnvMaxEgressRules, "-1")) + require.Equal(t, constants.DefaultMaxEgressRules, maxEgressRulesFromEnv(), "negative falls back to default") +} + +func TestHandlePatch_RejectsWhenOverMaxEgressRules(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "a.example.com"}, + {Action: policy.ActionAllow, Target: "b.example.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft", maxEgressRules: 2} + + body := `[{"action":"allow","target":"c.example.com"}]` + req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "expected 400 when merged egress exceeds max") + require.Equal(t, 0, nft.calls, "nft should not apply on rejection") + require.Len(t, proxy.updated.Egress, 2, "policy should be unchanged") +} + +func TestHandleDelete_RemovesMatchingTargets(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "example.com"}, + {Action: policy.ActionDeny, Target: "blocked.com"}, + {Action: policy.ActionAllow, Target: "keep.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["blocked.com","nonexistent.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.NotNil(t, proxy.updated, "expected proxy policy updated") + require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "defaultAction should be preserved") + require.Len(t, proxy.updated.Egress, 2, "expected 2 rules remaining after delete") + require.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action) + require.Equal(t, "example.com", proxy.updated.Egress[0].Target) + require.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action) + require.Equal(t, "keep.com", proxy.updated.Egress[1].Target) +} + +func TestHandleDelete_CaseInsensitiveMatch(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "Example.COM"}, + {Action: policy.ActionDeny, Target: "Blocked.COM"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["example.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") + require.NotNil(t, proxy.updated) + require.Len(t, proxy.updated.Egress, 1, "expected 1 rule remaining") + require.Equal(t, "Blocked.COM", proxy.updated.Egress[0].Target, "unmatched rule should remain") +} + +func TestHandleDelete_NoMatchReturns200(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "keep.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["nonexistent.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK even when no targets match") + require.Equal(t, 0, nft.calls, "nft should not be called when nothing changes") + require.Len(t, proxy.updated.Egress, 1, "policy should be unchanged") +} + +func TestHandleDelete_EmptyBodyReturns400(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader("")) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty body") +} + +func TestHandleDelete_EmptyArrayReturns400(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + body := `[]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for empty array") +} + +func TestHandleDelete_InvalidJSONReturns400(t *testing.T) { + proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} + srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} + + body := `not-json` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for invalid JSON") +} + +func TestHandleDelete_NftFailureReturns500(t *testing.T) { + initial := &policy.NetworkPolicy{ + DefaultAction: policy.ActionDeny, + Egress: []policy.EgressRule{ + {Action: policy.ActionAllow, Target: "example.com"}, + }, + } + proxy := &stubProxy{updated: initial} + nft := &stubNft{err: errors.New("nft apply failed")} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} + + body := `["example.com"]` + req := httptest.NewRequest(http.MethodDelete, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected 500 on nft failure") + require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") + require.Len(t, proxy.updated.Egress, 1, "proxy should not be updated on nft failure") + require.Equal(t, "example.com", proxy.updated.Egress[0].Target, "original rule should remain") +} + +func TestHandlePost_RejectsWhenOverMaxEgressRules(t *testing.T) { + proxy := &stubProxy{} + nft := &stubNft{} + srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft", maxEgressRules: 1} + + body := `{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"},{"action":"allow","target":"8.8.8.8"}]}` + req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(body)) + w := httptest.NewRecorder() + + srv.handlePolicy(w, req) + + resp := w.Result() + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode, "expected 400") + require.Nil(t, proxy.updated, "policy should not update") + require.Equal(t, 0, nft.calls, "nft should not apply") +} + +func mustRule(t *testing.T, action, target string) policy.EgressRule { + t.Helper() + r, err := policy.ParseValidatedEgressRule(action, target) + require.NoError(t, err) + return r +} + +func TestFingerprintRules_OrderIndependent(t *testing.T) { + a := mustRule(t, policy.ActionDeny, "1.1.1.1") + b := mustRule(t, policy.ActionDeny, "2.2.2.2") + c := mustRule(t, policy.ActionAllow, "example.com") + d := mustRule(t, policy.ActionAllow, "foo.test") + + fp1 := fingerprintRules([]policy.EgressRule{a, b}, []policy.EgressRule{c, d}) + fp2 := fingerprintRules([]policy.EgressRule{b, a}, []policy.EgressRule{d, c}) + require.Equal(t, fp1, fp2, "fingerprint must be order-independent within each set") +} + +func TestFingerprintRules_DenyAllowSetsDistinct(t *testing.T) { + denyX := mustRule(t, policy.ActionDeny, "1.1.1.1") + allowX := mustRule(t, policy.ActionAllow, "1.1.1.1") + + fpDeny := fingerprintRules([]policy.EgressRule{denyX}, nil) + fpAllow := fingerprintRules(nil, []policy.EgressRule{allowX}) + require.NotEqual(t, fpDeny, fpAllow, "deny X and allow X must not collide via set separator") +} + +func TestFingerprintRules_DetectsAddRemove(t *testing.T) { + a := mustRule(t, policy.ActionDeny, "1.1.1.1") + b := mustRule(t, policy.ActionDeny, "2.2.2.2") + + fp1 := fingerprintRules([]policy.EgressRule{a}, nil) + fp2 := fingerprintRules([]policy.EgressRule{a, b}, nil) + require.NotEqual(t, fp1, fp2, "adding a rule must change fingerprint") + + fp3 := fingerprintRules([]policy.EgressRule{a, b}, nil) + fp4 := fingerprintRules([]policy.EgressRule{a}, nil) + require.NotEqual(t, fp3, fp4, "removing a rule must change fingerprint") +} + +func TestFingerprintRules_DetectsActionChange(t *testing.T) { + deny := mustRule(t, policy.ActionDeny, "1.1.1.1") + allow := mustRule(t, policy.ActionAllow, "1.1.1.1") + + fp1 := fingerprintRules([]policy.EgressRule{deny}, nil) + fp2 := fingerprintRules([]policy.EgressRule{allow}, nil) + require.NotEqual(t, fp1, fp2, "flipping action on same target must change fingerprint") +} + +func TestFingerprintRules_EmptyStable(t *testing.T) { + fp1 := fingerprintRules(nil, nil) + fp2 := fingerprintRules([]policy.EgressRule{}, []policy.EgressRule{}) + require.Equal(t, fp1, fp2, "nil and empty slices must produce same fingerprint") +} diff --git a/components/egress/policy_utils.go b/components/egress/policy_utils.go new file mode 100644 index 0000000..938c1fb --- /dev/null +++ b/components/egress/policy_utils.go @@ -0,0 +1,211 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "io" + "net/http" + "os" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/egress/pkg/constants" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/policy" + slogger "github.com/alibaba/opensandbox/internal/logger" +) + +const maxPolicyBodyBytes = 1 << 20 + +func readPolicyRequestBody(r *http.Request) (string, error) { + body, err := io.ReadAll(io.LimitReader(r.Body, maxPolicyBodyBytes)) + if err != nil { + return "", err + } + raw := strings.TrimSpace(string(body)) + log.Infof("policy API: request body (%s %s): %s", r.Method, r.URL.Path, raw) + return raw, nil +} + +func patchMergedPolicy(base *policy.NetworkPolicy, patchRules []policy.EgressRule) (*policy.NetworkPolicy, error) { + if base == nil { + base = policy.DefaultDenyPolicy() + } + baseCopy := *base + baseCopy.Egress = append([]policy.EgressRule(nil), base.Egress...) + + merged := mergeEgressRules(baseCopy.Egress, patchRules) + rawMerged, err := json.Marshal(policy.NetworkPolicy{ + DefaultAction: baseCopy.DefaultAction, + Egress: merged, + }) + if err != nil { + return nil, err + } + return policy.ParsePolicy(string(rawMerged)) +} + +func mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule { + if len(additions) == 0 { + return base + } + out := make([]policy.EgressRule, 0, len(base)+len(additions)) + seen := make(map[string]struct{}) + + // patch rules win on same target; base fills the rest + for _, r := range additions { + key := mergeKey(r) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, r) + } + for _, r := range base { + key := mergeKey(r) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, r) + } + return out +} + +// removeRulesByTarget returns a new slice with rules matching targets removed, +// plus the removed rules. Domain targets are matched case-insensitively. +// Targets not found are silently ignored. +func removeRulesByTarget(rules []policy.EgressRule, targets []string) (kept, removed []policy.EgressRule) { + if len(targets) == 0 || len(rules) == 0 { + return rules, nil + } + removeSet := make(map[string]struct{}, len(targets)) + for _, t := range targets { + key := strings.ToLower(strings.TrimSpace(t)) + if key == "" { + continue + } + removeSet[key] = struct{}{} + } + kept = make([]policy.EgressRule, 0, len(rules)) + for _, r := range rules { + if _, ok := removeSet[strings.ToLower(r.Target)]; ok { + removed = append(removed, r) + } else { + kept = append(kept, r) + } + } + return kept, removed +} + +// mergeKey: domain targets lowercased for dedupe; IP/CIDR left as-is. +func mergeKey(r policy.EgressRule) string { + if r.Target == "" { + return r.Target + } + return strings.ToLower(r.Target) +} + +func maxEgressRulesFromEnv() int { + s := strings.TrimSpace(os.Getenv(constants.EnvMaxEgressRules)) + if s == "" { + return constants.DefaultMaxEgressRules + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return constants.DefaultMaxEgressRules + } + if n == 0 { + return 0 + } + return n +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func modeFromPolicy(p *policy.NetworkPolicy) string { + if p == nil { + return "deny_all" + } + if p.DefaultAction == policy.ActionAllow && len(p.Egress) == 0 { + return "allow_all" + } else if p.DefaultAction == policy.ActionDeny && len(p.Egress) == 0 { + return "deny_all" + } + + return "enforcing" +} + +func policyRuleSummary(p *policy.NetworkPolicy) []map[string]string { + if p == nil { + return nil + } + return egressRulesSummary(p.Egress) +} + +func egressRulesSummary(egress []policy.EgressRule) []map[string]string { + out := make([]map[string]string, 0, len(egress)) + for _, r := range egress { + out = append(out, map[string]string{ + "action": r.Action, + "target": r.Target, + }) + } + return out +} + +func logEgressLoaded(pol *policy.NetworkPolicy) { + if pol == nil { + pol = policy.DefaultDenyPolicy() + } + fields := []slogger.Field{ + {Key: "opensandbox.event", Value: "egress.loaded"}, + {Key: "egress.default", Value: pol.DefaultAction}, + {Key: "rules", Value: policyRuleSummary(pol)}, + } + log.Logger.With(fields...).Infof("egress policy loaded") +} + +// logEgressUpdated: egress.updated event. rules is only the delta for this request (PATCH: patch list; +// POST/PUT: full body egress; reset: empty), defaultAction is the policy after apply. +func logEgressUpdated(defaultAction string, deltaEgress []policy.EgressRule) { + fields := []slogger.Field{ + {Key: "opensandbox.event", Value: "egress.updated"}, + {Key: "egress.default", Value: defaultAction}, + {Key: "rules", Value: egressRulesSummary(deltaEgress)}, + } + log.Logger.With(fields...).Infof("egress policy updated") +} + +func logEgressUpdateFailedWarn(msg string) { + fields := []slogger.Field{ + {Key: "opensandbox.event", Value: "egress.update_failed"}, + {Key: "error", Value: msg}, + } + log.Logger.With(fields...).Warnf("egress policy update failed") +} + +func logEgressUpdateFailedError(msg string) { + fields := []slogger.Field{ + {Key: "opensandbox.event", Value: "egress.update_failed"}, + {Key: "error", Value: msg}, + } + log.Logger.With(fields...).Errorf("egress policy update failed") +} diff --git a/components/egress/scripts/cleanup.sh b/components/egress/scripts/cleanup.sh new file mode 100755 index 0000000..8e62d38 --- /dev/null +++ b/components/egress/scripts/cleanup.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Pre-start hook for opensandbox-supervisor wrapping the egress worker. +# Reaps any mitmdump left over from a previous crashed egress so the next +# launch can bind the transparent-MITM listen port (default 18081), and +# tears down stale iptables DNS redirect rules so DNS queries reach the +# real nameserver while the new egress process is still initializing. +# +# Scope: +# * iptables NAT OUTPUT rules for port 53 ARE removed here. Without +# this, a crashed egress leaves REDIRECT rules pointing at a dead +# proxy (127.0.0.1:15353), causing DNS resolution failure for the +# entire restart window. The new egress process re-installs them +# after its DNS proxy is listening, so the unfiltered window is +# limited to the startup duration (~200ms). +# * The `inet/ip/ip6 opensandbox_dns_redirect` nft tables ARE removed +# here. Native nft DNS fallback uses those tables when iptables-nft +# cannot append OUTPUT rules; after a crash they would otherwise keep +# redirecting DNS to a dead proxy. +# * The `inet opensandbox` nft table is NOT touched here. The egress +# nftables manager already prepends `delete table inet opensandbox` to +# its ruleset script, so ApplyStatic is idempotent. +# +# Hard contract: this script MUST NOT exit non-zero. A misbehaving cleanup +# hook is worse than a stray mitmdump; supervisor would treat the hook +# failure as a launch attempt and trip its crashloop budget faster. + +# Intentionally no `set -e`. `set -u` for typo safety on env names only. +set -u + +log() { printf '[egress-cleanup] %s\n' "$*" >&2; } + +# Wraps a command so non-zero exit is silently absorbed. Output goes to +# stderr so it shows up in container logs without polluting the event log. +try() { "$@" 2>&1 | sed 's/^/ /' >&2; return 0; } + +# ─── stale iptables rules (DNS redirect + mitmproxy transparent) ──── +# When egress crashes, iptables REDIRECT rules survive in the shared +# network namespace. DNS rules point port 53 at a dead proxy (15353); +# mitmproxy rules point 80/443 at a dead mitmdump. Remove both so +# traffic flows normally until the new egress re-installs them. +remove_stale_iptables() { + for cmd in iptables ip6tables; do + command -v "$cmd" >/dev/null 2>&1 || continue + # Delete all nat/OUTPUT rules that redirect port 53 (DNS proxy). + "$cmd" -t nat -S OUTPUT 2>/dev/null \ + | awk '/--dport 53/ {sub(/^-A/,"-D"); print}' \ + | while IFS= read -r rule; do + eval "$cmd -t nat $rule" 2>/dev/null || true + done + # Delete all nat/OUTPUT rules that redirect ports 80,443 (mitmproxy transparent). + "$cmd" -t nat -S OUTPUT 2>/dev/null \ + | awk '/--dports 80,443/ {sub(/^-A/,"-D"); print}' \ + | while IFS= read -r rule; do + eval "$cmd -t nat $rule" 2>/dev/null || true + done + done + log "stale iptables rules removed (best-effort)" +} + +remove_stale_dns_redirect_nft() { + command -v nft >/dev/null 2>&1 || { log "nft not present; skipping native DNS redirect cleanup"; return 0; } + for family in inet ip ip6; do + printf 'delete table %s opensandbox_dns_redirect\n' "$family" | nft -f - 2>/dev/null || true + done + log "stale native DNS redirect nft tables removed (best-effort)" +} + +# ─── stray mitmdump (orphaned after hard crash) ────────────────────── +kill_stray_mitmdump() { + command -v pkill >/dev/null 2>&1 || { log "pkill not present; skipping mitmdump reap"; return 0; } + # mitmdump runs as the `mitmproxy` user (uid 10042 per egress Dockerfile). + # `-u mitmproxy` scopes pkill to that uid so we never touch anything else; + # `-f mitmdump` is the cmdline match safety net inside that uid. + # SIGTERM first; give it a moment; SIGKILL anything that ignored TERM. + try pkill -TERM -u mitmproxy -f mitmdump + # Short sleep, but bounded so this hook still finishes inside the + # supervisor's PreStartTimeout (default 30s) with plenty of headroom. + sleep 1 + try pkill -KILL -u mitmproxy -f mitmdump + log "stray mitmdump processes reaped (best-effort)" +} + +main() { + log "starting (worker_exit_code=${WORKER_EXIT_CODE:-?} signal=${WORKER_SIGNAL:-?} attempt=${WORKER_ATTEMPT:-?})" + remove_stale_dns_redirect_nft + remove_stale_iptables + kill_stray_mitmdump + log "done" + exit 0 +} + +# Trap unexpected interpreter errors so we still exit 0. +trap 'log "cleanup hit shell error on line $LINENO; exiting 0 anyway"; exit 0' HUP INT TERM +main "$@" || true +exit 0 diff --git a/components/egress/shutdown.go b/components/egress/shutdown.go new file mode 100644 index 0000000..7c51c6a --- /dev/null +++ b/components/egress/shutdown.go @@ -0,0 +1,72 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "net/http" + "net/netip" + "os" + "time" + + "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" + "github.com/alibaba/opensandbox/egress/pkg/iptables" + "github.com/alibaba/opensandbox/egress/pkg/log" + "github.com/alibaba/opensandbox/egress/pkg/mitmproxy" +) + +const ( + defaultPolicyShutdownTimeout = 5 * time.Second + defaultNftTeardownTimeout = 5 * time.Second + defaultMitmShutdownTimeout = 5 * time.Second +) + +func waitForShutdown(ctx context.Context, proxy *dnsproxy.Proxy, policySrv *http.Server, exemptDst []netip.Addr, applier nftApplier, mitm *mitmTransparent) { + <-ctx.Done() + log.Infof("received shutdown signal; beginning graceful shutdown") + + policyShutdownCtx, policyCancel := context.WithTimeout(context.Background(), defaultPolicyShutdownTimeout) + defer policyCancel() + + if policySrv != nil { + if err := policySrv.Shutdown(policyShutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("policy server shutdown error: %v", err) + } + } + + proxy.SetOnResolved(nil) + + if err := proxy.Shutdown(); err != nil { + log.Errorf("dns proxy shutdown error: %v", err) + } + + if mitm != nil { + iptables.RemoveTransparentHTTP(mitm.port, mitm.uid) + mitmproxy.GracefulShutdown(mitm.getRunning(), defaultMitmShutdownTimeout) + } + iptables.RemoveRedirect(15353, exemptDst) + + if applier != nil { + nftCtx, nftCancel := context.WithTimeout(context.Background(), defaultNftTeardownTimeout) + defer nftCancel() + if err := applier.RemoveEnforcement(nftCtx); err != nil { + log.Errorf("nftables teardown error: %v", err) + } + } + + log.Infof("egress shutdown complete") + _ = os.Stderr.Sync() +} diff --git a/components/egress/tests/bench-dns-nft.sh b/components/egress/tests/bench-dns-nft.sh new file mode 100755 index 0000000..323eb64 --- /dev/null +++ b/components/egress/tests/bench-dns-nft.sh @@ -0,0 +1,314 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# E2E benchmark: baseline (no egress) vs dns (pass-through) vs dns+nft (sync dynamic IP write). +# Baseline: plain curl container, same workload, no container. Then egress dns and dns+nft. +# Metrics: E2E latency (p50, p99), throughput (req/s). +# +# Usage: ./tests/bench-dns-nft.sh +# Optional: BENCH_SAMPLE_SIZE=n to randomly use n domains from hostname.txt (default: use all). +# Requires: Docker, curl in PATH (for policy push). Egress image and baseline image (default curlimages/curl:latest) must have curl. +# Domain list: tests/hostname.txt (one domain per line). + +set -euo pipefail + +info() { echo "[$(date +%H:%M:%S)] $*"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOSTNAME_FILE="${SCRIPT_DIR}/hostname.txt" +# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +IMG="opensandbox/egress:local" +BASELINE_IMG="${BASELINE_IMG:-curlimages/curl:latest}" +CONTAINER_NAME="egress-bench-e2e" +POLICY_PORT=18080 +ROUNDS=10 +# Optional: where to write egress logs on host. Override via LOG_HOST_DIR / LOG_FILE. +LOG_HOST_DIR="${LOG_HOST_DIR:-/tmp/egress-logs}" +LOG_FILE="${LOG_FILE:-egress.log}" +LOG_CONTAINER_DIR="/var/log/opensandbox" +LOG_CONTAINER_FILE="${LOG_CONTAINER_DIR}/${LOG_FILE}" + +# Load benchmark domains from hostname.txt (one domain per line). +if [[ ! -f "${HOSTNAME_FILE}" ]] || [[ ! -s "${HOSTNAME_FILE}" ]]; then + echo "Error: domain file not found or empty: ${HOSTNAME_FILE}" >&2 + exit 1 +fi +BENCH_DOMAINS=() +while IFS= read -r line; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -n "$line" ]] && BENCH_DOMAINS+=( "$line" ) +done < "${HOSTNAME_FILE}" +total_in_file=${#BENCH_DOMAINS[@]} +if [[ "$total_in_file" -eq 0 ]]; then + echo "Error: no domains in ${HOSTNAME_FILE}" >&2 + exit 1 +fi + +# Optionally randomly sample n domains (BENCH_SAMPLE_SIZE); if unset or 0, use all. +if [[ -n "${BENCH_SAMPLE_SIZE:-}" ]] && [[ "${BENCH_SAMPLE_SIZE}" -gt 0 ]]; then + if [[ "${BENCH_SAMPLE_SIZE}" -ge "$total_in_file" ]]; then + NUM_DOMAINS=$total_in_file + else + # Portable shuffle: shuf (Linux), gshuf (macOS coreutils), else awk + if command -v shuf >/dev/null 2>&1; then + BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | shuf -n "${BENCH_SAMPLE_SIZE}") ) + elif command -v gshuf >/dev/null 2>&1; then + BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | gshuf -n "${BENCH_SAMPLE_SIZE}") ) + else + BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | awk 'BEGIN{srand()} {printf "%s\t%s\n", rand(), $0}' | sort -n | cut -f2- | head -n "${BENCH_SAMPLE_SIZE}") ) + fi + NUM_DOMAINS=${#BENCH_DOMAINS[@]} + info "Using ${NUM_DOMAINS} randomly sampled domains (of ${total_in_file}) from ${HOSTNAME_FILE}" + fi +else + NUM_DOMAINS=$total_in_file +fi +TOTAL_REQUESTS=$((ROUNDS * NUM_DOMAINS)) +CURL_TIMEOUT=10 +# Max wall time for the benchmark loop (docker exec); avoid hanging forever. +BENCH_EXEC_TIMEOUT=300 + +cleanup() { + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +# Compute stats from a file with one numeric value per line (e.g. time_total in seconds). +# Output: count avg_s p50_s p99_s +stats() { + local file="$1" + if [[ ! -f "$file" ]] || [[ ! -s "$file" ]]; then + echo "0 0 0 0" + return + fi + sort -n "$file" > "${file}.sorted" + local n + n=$(wc -l < "${file}.sorted") + if [[ "$n" -eq 0 ]]; then + echo "0 0 0 0" + return + fi + local avg p50 p99 + avg=$(awk '{s+=$1; c++} END { if(c>0) print s/c; else print 0 }' "$file") + p50=$(awk -v n="$n" 'NR==int(n*0.5+0.5){print $1; exit}' "${file}.sorted") + p99=$(awk -v n="$n" 'NR==int(n*0.99+0.5){print $1; exit}' "${file}.sorted") + echo "$n $avg $p50 $p99" +} + +# Run workload inside CONTAINER_NAME; /tmp/bench-domains.txt must already exist in container. +# Usage: run_bench_to [limit] [rounds] [timeout] +run_bench_to() { + local outfile="$1" + local limit="${2:-9999}" + local rounds="${3:-1}" + local use_timeout="${4:-}" + local cmd=( + docker exec -e BENCH_TIMEOUT="${CURL_TIMEOUT}" -e BENCH_OUTFILE="${outfile}" -e BENCH_LIMIT="${limit}" -e BENCH_ROUNDS="${rounds}" \ + "${CONTAINER_NAME}" sh -c ' + : > "$BENCH_OUTFILE" + r=1 + while [ "$r" -le "$BENCH_ROUNDS" ]; do + n=0 + while IFS= read -r url && [ "$n" -lt "$BENCH_LIMIT" ]; do + ( curl -o /dev/null -s -I -w "%{time_namelookup}\t%{time_total}\n" --max-time "$BENCH_TIMEOUT" "$url" >> "$BENCH_OUTFILE" ) & + n=$((n+1)) + done < /tmp/bench-domains.txt + wait + r=$((r+1)) + done + ' + ) + if [[ "$use_timeout" == "timeout" ]] && command -v timeout >/dev/null 2>&1; then + timeout "${BENCH_EXEC_TIMEOUT}" "${cmd[@]}" + else + "${cmd[@]}" + fi +} + +# Copy URL file into container (create temp file, docker cp, rm). Uses BENCH_DOMAINS. +copy_url_file_to_container() { + local url_file="/tmp/bench-e2e-domains-$$.txt" + : > "${url_file}" + for d in "${BENCH_DOMAINS[@]}"; do + echo "https://${d}" >> "${url_file}" + done + docker cp "${url_file}" "${CONTAINER_NAME}:/tmp/bench-domains.txt" + rm -f "${url_file}" +} + +# Run warm-up + timed benchmark, collect timings. Writes /tmp/bench-e2e-{mode}-total.txt, -namelookup.txt, -wall.txt. +# Requires: CONTAINER_NAME running, /tmp/bench-domains.txt inside container. +run_workload() { + local mode="$1" + local out_total="/tmp/bench-e2e-${mode}-total.txt" + local out_namelookup="/tmp/bench-e2e-${mode}-namelookup.txt" + : > "$out_total" + : > "$out_namelookup" + + local first_url="https://${BENCH_DOMAINS[0]}" + sleep 1 + # HEAD request: no response body, only check DNS + TCP + TLS + HTTP response. + if ! docker exec "${CONTAINER_NAME}" curl -o /dev/null -s -I --max-time "${CURL_TIMEOUT}" "${first_url}"; then + info "Warm-up curl failed; stderr from one attempt:" + docker exec "${CONTAINER_NAME}" curl -o /dev/null -s -I --max-time 5 "${first_url}" 2>&1 || true + return 1 + fi + + info "Warm-up: first 10 domains, 1 round..." + bench_ret=0 + run_bench_to /tmp/bench-warmup.txt 10 1 2>/tmp/bench-e2e-stderr.txt || bench_ret=$? + if [[ "$bench_ret" -ne 0 ]]; then + info "Warm-up run failed (exit $bench_ret); continuing with timed run anyway." + fi + + info "Running ${TOTAL_REQUESTS} E2E requests (${ROUNDS} rounds × ${NUM_DOMAINS} domains) inside container (max ${BENCH_EXEC_TIMEOUT}s)..." + local start_ts + start_ts=$(date +%s.%N) + bench_ret=0 + run_bench_to /tmp/bench-raw.txt 9999 "${ROUNDS}" timeout 2>/tmp/bench-e2e-stderr.txt || bench_ret=$? + if [[ "$bench_ret" -ne 0 ]]; then + info "Benchmark run failed (exit $bench_ret) or hit timeout; using partial results if any." + fi + docker cp "${CONTAINER_NAME}:/tmp/bench-raw.txt" /tmp/bench-e2e-raw.txt 2>/dev/null || true + local end_ts + end_ts=$(date +%s.%N) + + if [[ -s /tmp/bench-e2e-stderr.txt ]]; then + info "docker exec stderr (first 10 lines):" + head -10 /tmp/bench-e2e-stderr.txt >&2 + fi + if [[ ! -f /tmp/bench-e2e-raw.txt ]]; then + : > /tmp/bench-e2e-raw.txt + fi + local lines + lines=$(wc -l < /tmp/bench-e2e-raw.txt 2>/dev/null || echo 0) + if [[ "$lines" -lt $((TOTAL_REQUESTS / 2)) ]]; then + info "WARN: only ${lines}/${TOTAL_REQUESTS} responses captured; curl may be failing inside container." + fi + + awk -F'\t' '{print $2}' /tmp/bench-e2e-raw.txt 2>/dev/null > "$out_total" + awk -F'\t' '{print $1}' /tmp/bench-e2e-raw.txt 2>/dev/null > "$out_namelookup" + local wall_s + wall_s=$(awk -v s="$start_ts" -v e="$end_ts" 'BEGIN { print e - s }') + echo "$wall_s" > "/tmp/bench-e2e-${mode}-wall.txt" +} + +# Run one benchmark phase: start container with given mode, push policy, run client workload, collect timings. +# Usage: run_phase "dns" | "dns+nft" +run_phase() { + local mode="$1" + info "Phase: ${mode}" + cleanup + mkdir -p "${LOG_HOST_DIR}" + docker run -d --name "${CONTAINER_NAME}" \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + -e OPENSANDBOX_EGRESS_MODE="${mode}" \ + -e OPENSANDBOX_LOG_OUTPUT="${LOG_CONTAINER_FILE}" \ + -v "${LOG_HOST_DIR}:${LOG_CONTAINER_DIR}" \ + -p "${POLICY_PORT}:18080" \ + "${IMG}" + + for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.5 + done + + local policy_egress="" + for d in "${BENCH_DOMAINS[@]}"; do + policy_egress="${policy_egress}{\"action\":\"allow\",\"target\":\"${d}\"}," + done + policy_egress="${policy_egress%,}" + local policy_json="{\"defaultAction\":\"deny\",\"egress\":[${policy_egress}]}" + curl -sf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" -d "${policy_json}" >/dev/null + + copy_url_file_to_container + run_workload "${mode}" +} + +# Run baseline phase: plain curl container, no egress container. Same workload for comparison. +run_phase_baseline() { + info "Phase: baseline (no egress)" + cleanup + docker pull "${BASELINE_IMG}" > /dev/null 2>&1 + docker run -d --name "${CONTAINER_NAME}" "${BASELINE_IMG}" sleep 3600 + sleep 2 + copy_url_file_to_container + run_workload "baseline" +} + +# Print comparison table (baseline, dns, dns+nft) +report() { + local nb n1 n2 avg0 avg1 avg2 p50_0 p50_1 p50_2 p99_0 p99_1 p99_2 wall0 wall1 wall2 + read -r nb avg0 p50_0 p99_0 <<< "$(stats /tmp/bench-e2e-baseline-total.txt)" + read -r n1 avg1 p50_1 p99_1 <<< "$(stats /tmp/bench-e2e-dns-total.txt)" + read -r n2 avg2 p50_2 p99_2 <<< "$(stats /tmp/bench-e2e-dns+nft-total.txt)" + wall0=$(cat /tmp/bench-e2e-baseline-wall.txt 2>/dev/null || echo "0") + wall1=$(cat /tmp/bench-e2e-dns-wall.txt 2>/dev/null || echo "0") + wall2=$(cat /tmp/bench-e2e-dns+nft-wall.txt 2>/dev/null || echo "0") + if [[ "${nb:-0}" -eq 0 ]] || [[ "${n1:-0}" -eq 0 ]] || [[ "${n2:-0}" -eq 0 ]]; then + echo "WARN: some phases had no successful requests; check container logs and network." + fi + + local rps0 rps1 rps2 + rps0=$(awk -v n="$nb" -v w="$wall0" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') + rps1=$(awk -v n="$n1" -v w="$wall1" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') + rps2=$(awk -v n="$n2" -v w="$wall2" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') + + echo "" + echo "========== E2E benchmark: baseline vs dns vs dns+nft ==========" + echo "Workload: ${TOTAL_REQUESTS} requests (${ROUNDS} rounds × ${NUM_DOMAINS} domains)" + echo "" + local ov_avg1 ov_p50_1 ov_p99_1 ov_rps1 ov_avg2 ov_p50_2 ov_p99_2 ov_rps2 + ov_avg1=$(awk -v a="$avg1" -v b="$avg0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p50_1=$(awk -v a="$p50_1" -v b="$p50_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p99_1=$(awk -v a="$p99_1" -v b="$p99_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_rps1=$(awk -v a="$rps1" -v b="$rps0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (b-a)/b*100 : 0 }') + ov_avg2=$(awk -v a="$avg2" -v b="$avg0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p50_2=$(awk -v a="$p50_2" -v b="$p50_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p99_2=$(awk -v a="$p99_2" -v b="$p99_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_rps2=$(awk -v a="$rps2" -v b="$rps0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (b-a)/b*100 : 0 }') + + printf "%-10s %14s %20s %20s %20s\n" "Mode" "Req/s" "Avg(s)" "P50(s)" "P99(s)" + printf "%-10s %14s %20s %20s %20s\n" "baseline" "$rps0" "$avg0" "$p50_0" "$p99_0" + printf "%-10s %14s %20s %20s %20s\n" "dns" "$(printf '%.2f(%s%%)' "$rps1" "$ov_rps1")" "$(printf '%.3f(%s%%)' "$avg1" "$ov_avg1")" "$(printf '%.3f(%s%%)' "$p50_1" "$ov_p50_1")" "$(printf '%.3f(%s%%)' "$p99_1" "$ov_p99_1")" + printf "%-10s %14s %20s %20s %20s\n" "dns+nft" "$(printf '%.2f(%s%%)' "$rps2" "$ov_rps2")" "$(printf '%.3f(%s%%)' "$avg2" "$ov_avg2")" "$(printf '%.3f(%s%%)' "$p50_2" "$ov_p50_2")" "$(printf '%.3f(%s%%)' "$p99_2" "$ov_p99_2")" + echo "" + echo "Overhead in parentheses vs baseline: latency +%% = slower, Req/s -%% = lower throughput." + echo "baseline: Plain container (${BASELINE_IMG}), no egress container." + echo "dns: DNS proxy only, no nft write (pass-through)." + echo "dns+nft: DNS proxy + sync AddResolvedIPs before each DNS reply (L2 enforcement)." + echo "" + echo "Note: Warm-up runs before each phase. Baseline gives no-proxy comparison." + echo "==========" +} + +info "Building image ${IMG}" +docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" > /dev/null 2>&1 + +run_phase_baseline +run_phase "dns+nft" +run_phase "dns" +report +info "Cleaning up" +cleanup diff --git a/components/egress/tests/bench-mitm-overhead.sh b/components/egress/tests/bench-mitm-overhead.sh new file mode 100755 index 0000000..5d54c69 --- /dev/null +++ b/components/egress/tests/bench-mitm-overhead.sh @@ -0,0 +1,567 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# E2E benchmark: dns+nft vs dns+nft + transparent mitmproxy (HTTPS MITM). +# Two workload scenarios (configurable via BENCH_SCENARIOS): +# short — high concurrency short HTTPS requests (HEAD to many hosts) +# download — large payload download (parallel GETs to one URL) +# +# Usage (from repo root or components/egress): +# ./tests/bench-mitm-overhead.sh +# Optional: +# SKIP_BUILD=1 — use existing ${IMG} image +# BENCH_SAMPLE_SIZE=n — sample n random domains from hostname.txt +# BENCH_SCENARIOS=short,download — comma-separated; default both +# Short: BENCH_SHORT_INFLIGHT_PER_DOMAIN=n — parallel HEADs per URL per round (default 1); raise for higher concurrency +# Download: BENCH_DOWNLOAD_URL, BENCH_DOWNLOAD_PARALLEL, BENCH_DOWNLOAD_ROUNDS, BENCH_DOWNLOAD_MAXTIME +# BENCH_DOCKER_STATS_INTERVAL=1 — sampling interval seconds for docker stats + /proc/loadavg (default 1) +# +# Requires: Docker, curl on host. Domain list: tests/hostname.txt. + +set -euo pipefail + +info() { echo "[$(date +%H:%M:%S)] $*"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOSTNAME_FILE="${SCRIPT_DIR}/hostname.txt" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +IMG="opensandbox/egress:local" +CONTAINER_NAME="egress-bench-mitm" +POLICY_PORT=18080 +ROUNDS=10 +LOG_HOST_DIR="${LOG_HOST_DIR:-/tmp/egress-logs}" +LOG_FILE="${LOG_FILE:-egress.log}" +LOG_CONTAINER_DIR="/var/log/opensandbox" +LOG_CONTAINER_FILE="${LOG_CONTAINER_DIR}/${LOG_FILE}" + +# Scenarios: short, download (comma-separated) +BENCH_SCENARIOS="${BENCH_SCENARIOS:-short,download}" +# Short: parallel HEAD requests per domain per round (1 = same as classic single-flight per URL) +BENCH_SHORT_INFLIGHT_PER_DOMAIN="${BENCH_SHORT_INFLIGHT_PER_DOMAIN:-1}" +# Download: one large HTTPS object, many parallel streams +BENCH_DOWNLOAD_URL="${BENCH_DOWNLOAD_URL:-https://speed.cloudflare.com/__down?bytes=20971520}" +BENCH_DOWNLOAD_PARALLEL="${BENCH_DOWNLOAD_PARALLEL:-4}" +BENCH_DOWNLOAD_ROUNDS="${BENCH_DOWNLOAD_ROUNDS:-1}" +BENCH_DOWNLOAD_MAXTIME="${BENCH_DOWNLOAD_MAXTIME:-600}" +BENCH_DOWNLOAD_EXEC_TIMEOUT="${BENCH_DOWNLOAD_EXEC_TIMEOUT:-900}" + +if [[ ! -f "${HOSTNAME_FILE}" ]] || [[ ! -s "${HOSTNAME_FILE}" ]]; then + echo "Error: domain file not found or empty: ${HOSTNAME_FILE}" >&2 + exit 1 +fi +BENCH_DOMAINS=() +while IFS= read -r line; do + line="${line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -n "$line" ]] && BENCH_DOMAINS+=( "$line" ) +done < "${HOSTNAME_FILE}" +total_in_file=${#BENCH_DOMAINS[@]} +if [[ "$total_in_file" -eq 0 ]]; then + echo "Error: no domains in ${HOSTNAME_FILE}" >&2 + exit 1 +fi + +if [[ -n "${BENCH_SAMPLE_SIZE:-}" ]] && [[ "${BENCH_SAMPLE_SIZE}" -gt 0 ]]; then + if [[ "${BENCH_SAMPLE_SIZE}" -ge "$total_in_file" ]]; then + NUM_DOMAINS=$total_in_file + else + if command -v shuf >/dev/null 2>&1; then + BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | shuf -n "${BENCH_SAMPLE_SIZE}") ) + elif command -v gshuf >/dev/null 2>&1; then + BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | gshuf -n "${BENCH_SAMPLE_SIZE}") ) + else + BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | awk 'BEGIN{srand()} {printf "%s\t%s\n", rand(), $0}' | sort -n | cut -f2- | head -n "${BENCH_SAMPLE_SIZE}") ) + fi + NUM_DOMAINS=${#BENCH_DOMAINS[@]} + info "Using ${NUM_DOMAINS} randomly sampled domains (of ${total_in_file})" + fi +else + NUM_DOMAINS=$total_in_file +fi + +TOTAL_REQUESTS=$((ROUNDS * NUM_DOMAINS * BENCH_SHORT_INFLIGHT_PER_DOMAIN)) +CURL_TIMEOUT=10 +BENCH_EXEC_TIMEOUT=300 + +# Hostname from https://host/path?query — for egress allow policy on download URL +download_url_host() { + local u="$1" + u="${u#http://}" + u="${u#https://}" + u="${u%%/*}" + u="${u%%:*}" + printf '%s\n' "$u" +} + +BENCH_DOWNLOAD_HOST="$(download_url_host "${BENCH_DOWNLOAD_URL}")" + +scenario_enabled() { + local s="$1" + echo ",${BENCH_SCENARIOS}," | grep -q ",${s}," +} + +cleanup() { + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +DOCKER_STATS_PID="" + +start_docker_stats_log() { + local outfile="$1" + local interval="${BENCH_DOCKER_STATS_INTERVAL:-1}" + ( + # load1/5/15 = cgroup view of load (same order as host loadavg; comparable under stress) + echo -e "unix_ts\tload1\tload5\tload15\trunnable\ttotal_tasks\tCPUPerc\tMemUsage\tMemPerc\tNetIO\tBlockIO" + while true; do + ts=$(date +%s) + la=$(docker exec "${CONTAINER_NAME}" cat /proc/loadavg 2>/dev/null) || la="" + load1=""; load5=""; load15=""; rrun=""; rtot="" + if [[ -n "$la" ]]; then + read -r load1 load5 load15 rt_field _ <<< "$la" + rrun="${rt_field%%/*}" + rtot="${rt_field#*/}" + fi + line=$(docker stats "${CONTAINER_NAME}" --no-stream --format "{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}" 2>/dev/null) || true + [[ -n "$line" ]] && echo -e "${ts}\t${load1}\t${load5}\t${load15}\t${rrun}\t${rtot}\t${line}" + sleep "${interval}" + done + ) >> "${outfile}" & + DOCKER_STATS_PID=$! +} + +stop_docker_stats_log() { + if [[ -n "${DOCKER_STATS_PID}" ]]; then + kill "${DOCKER_STATS_PID}" 2>/dev/null || true + wait "${DOCKER_STATS_PID}" 2>/dev/null || true + DOCKER_STATS_PID="" + fi +} + +stats() { + local file="$1" + if [[ ! -f "$file" ]] || [[ ! -s "$file" ]]; then + echo "0 0 0 0" + return + fi + sort -n "$file" > "${file}.sorted" + local n + n=$(wc -l < "${file}.sorted") + if [[ "$n" -eq 0 ]]; then + echo "0 0 0 0" + return + fi + local avg p50 p99 + avg=$(awk '{s+=$1; c++} END { if(c>0) print s/c; else print 0 }' "$file") + p50=$(awk -v n="$n" 'NR==int(n*0.5+0.5){print $1; exit}' "${file}.sorted") + p99=$(awk -v n="$n" 'NR==int(n*0.99+0.5){print $1; exit}' "${file}.sorted") + echo "$n $avg $p50 $p99" +} + +# Column 1: time_total; stats on latency only +stats_download_time() { + local file="$1" + if [[ ! -f "$file" ]] || [[ ! -s "$file" ]]; then + echo "0 0 0 0" + return + fi + awk -F'\t' '{print $1}' "$file" | sort -n > "${file}.time.sorted" + local n avg p50 p99 + n=$(wc -l < "${file}.time.sorted") + if [[ "$n" -eq 0 ]]; then + echo "0 0 0 0" + return + fi + avg=$(awk '{s+=$1; c++} END { if(c>0) print s/c; else print 0 }' "${file}.time.sorted") + p50=$(awk -v n="$n" 'NR==int(n*0.5+0.5){print $1; exit}' "${file}.time.sorted") + p99=$(awk -v n="$n" 'NR==int(n*0.99+0.5){print $1; exit}' "${file}.time.sorted") + echo "$n $avg $p50 $p99" +} + +# End-to-end request latency loss vs baseline. +# Args: with_mitm_avg_seconds baseline_avg_seconds +# Echo: " " +calc_e2e_latency_loss() { + local with_mitm_avg="$1" + local baseline_avg="$2" + local loss_ms loss_pct + loss_ms=$(awk -v a="$with_mitm_avg" -v b="$baseline_avg" 'BEGIN { printf "%.2f", (a-b)*1000 }') + loss_pct=$(awk -v a="$with_mitm_avg" -v b="$baseline_avg" 'BEGIN { if (b>0) printf "%+.2f", (a-b)/b*100; else print "+0.00" }') + echo "$loss_ms $loss_pct" +} + +# Sum of column 2 (size_download bytes) +sum_bytes() { + local file="$1" + awk -F'\t' '{s+=$2} END { print s+0 }' "$file" 2>/dev/null || echo "0" +} + +run_bench_short() { + local outfile="$1" + local limit="${2:-9999}" + local rounds="${3:-1}" + local use_timeout="${4:-}" + local inflight="${BENCH_SHORT_INFLIGHT_PER_DOMAIN:-1}" + local cmd=( + docker exec \ + -e BENCH_TIMEOUT="${CURL_TIMEOUT}" \ + -e BENCH_OUTFILE="${outfile}" \ + -e BENCH_LIMIT="${limit}" \ + -e BENCH_ROUNDS="${rounds}" \ + -e BENCH_INFLIGHT="${inflight}" \ + "${CONTAINER_NAME}" sh -c ' + : > "$BENCH_OUTFILE" + r=1 + while [ "$r" -le "$BENCH_ROUNDS" ]; do + n=0 + while IFS= read -r url && [ "$n" -lt "$BENCH_LIMIT" ]; do + k=0 + while [ "$k" -lt "$BENCH_INFLIGHT" ]; do + ( curl -o /dev/null -s -I -w "%{time_namelookup}\t%{time_total}\n" --max-time "$BENCH_TIMEOUT" "$url" >> "$BENCH_OUTFILE" ) & + k=$((k+1)) + done + n=$((n+1)) + done < /tmp/bench-domains.txt + wait + r=$((r+1)) + done + ' + ) + if [[ "$use_timeout" == "timeout" ]] && command -v timeout >/dev/null 2>&1; then + timeout "${BENCH_EXEC_TIMEOUT}" "${cmd[@]}" + else + "${cmd[@]}" + fi +} + +run_bench_download() { + local outfile="$1" + local use_timeout="$2" + local url="${BENCH_DOWNLOAD_URL}" + local par="${BENCH_DOWNLOAD_PARALLEL}" + local drounds="${BENCH_DOWNLOAD_ROUNDS}" + local maxt="${BENCH_DOWNLOAD_MAXTIME}" + local cmd=( + docker exec \ + -e BENCH_OUTFILE="${outfile}" \ + -e BENCH_DL_URL="${url}" \ + -e BENCH_DL_PAR="${par}" \ + -e BENCH_DL_ROUNDS="${drounds}" \ + -e BENCH_DL_MAX="${maxt}" \ + "${CONTAINER_NAME}" sh -c ' + : > "$BENCH_OUTFILE" + r=1 + while [ "$r" -le "$BENCH_DL_ROUNDS" ]; do + i=0 + while [ "$i" -lt "$BENCH_DL_PAR" ]; do + ( curl -L -o /dev/null -s -w "%{time_total}\t%{size_download}\n" --max-time "$BENCH_DL_MAX" "$BENCH_DL_URL" >> "$BENCH_OUTFILE" ) & + i=$((i+1)) + done + wait + r=$((r+1)) + done + ' + ) + if [[ "$use_timeout" == "timeout" ]] && command -v timeout >/dev/null 2>&1; then + timeout "${BENCH_DOWNLOAD_EXEC_TIMEOUT}" "${cmd[@]}" + else + "${cmd[@]}" + fi +} + +copy_url_file_to_container() { + local url_file="/tmp/bench-mitm-domains-$$.txt" + : > "${url_file}" + for d in "${BENCH_DOMAINS[@]}"; do + echo "https://${d}" >> "${url_file}" + done + docker cp "${url_file}" "${CONTAINER_NAME}:/tmp/bench-domains.txt" + rm -f "${url_file}" +} + +run_workload_short() { + local mode="$1" + local out_total="/tmp/bench-mitm-${mode}-short-total.txt" + local out_namelookup="/tmp/bench-mitm-${mode}-short-namelookup.txt" + : > "$out_total" + : > "$out_namelookup" + + local first_url="https://${BENCH_DOMAINS[0]}" + sleep 1 + if ! docker exec "${CONTAINER_NAME}" curl -o /dev/null -s -I --max-time "${CURL_TIMEOUT}" "${first_url}"; then + info "Warm-up curl failed for ${first_url}" + return 1 + fi + + info "Short workload: warm-up 10 domains × inflight=${BENCH_SHORT_INFLIGHT_PER_DOMAIN}..." + bench_ret=0 + run_bench_short /tmp/bench-warmup.txt 10 1 2>/tmp/bench-mitm-stderr.txt || bench_ret=$? + if [[ "$bench_ret" -ne 0 ]]; then + info "Warm-up run failed (exit $bench_ret); continuing." + fi + + info "Short workload: ${TOTAL_REQUESTS} HEAD requests (${ROUNDS} rounds × ${NUM_DOMAINS} URLs × ${BENCH_SHORT_INFLIGHT_PER_DOMAIN} inflight, max ${BENCH_EXEC_TIMEOUT}s)..." + local start_ts + start_ts=$(date +%s.%N) + bench_ret=0 + run_bench_short /tmp/bench-raw.txt 9999 "${ROUNDS}" timeout 2>/tmp/bench-mitm-stderr.txt || bench_ret=$? + if [[ "$bench_ret" -ne 0 ]]; then + info "Short benchmark failed or timeout (exit $bench_ret); using partial results if any." + fi + docker cp "${CONTAINER_NAME}:/tmp/bench-raw.txt" /tmp/bench-mitm-short-raw.txt 2>/dev/null || true + local end_ts + end_ts=$(date +%s.%N) + + if [[ -s /tmp/bench-mitm-stderr.txt ]]; then + head -5 /tmp/bench-mitm-stderr.txt >&2 + fi + if [[ ! -f /tmp/bench-mitm-short-raw.txt ]]; then + : > /tmp/bench-mitm-short-raw.txt + fi + awk -F'\t' '{print $2}' /tmp/bench-mitm-short-raw.txt 2>/dev/null > "$out_total" + awk -F'\t' '{print $1}' /tmp/bench-mitm-short-raw.txt 2>/dev/null > "$out_namelookup" + local wall_s + wall_s=$(awk -v s="$start_ts" -v e="$end_ts" 'BEGIN { print e - s }') + echo "$wall_s" > "/tmp/bench-mitm-${mode}-short-wall.txt" +} + +run_workload_download() { + local mode="$1" + local out_raw="/tmp/bench-mitm-${mode}-download-raw.tsv" + : > "$out_raw" + + info "Download workload: GET ${BENCH_DOWNLOAD_URL}" + info " parallel=${BENCH_DOWNLOAD_PARALLEL} rounds=${BENCH_DOWNLOAD_ROUNDS} maxtime=${BENCH_DOWNLOAD_MAXTIME}s (wall cap ${BENCH_DOWNLOAD_EXEC_TIMEOUT}s)" + + if ! docker exec "${CONTAINER_NAME}" curl -o /dev/null -sf -I --max-time "${CURL_TIMEOUT}" "${BENCH_DOWNLOAD_URL}"; then + info "Warm-up HEAD to download URL failed; trying full GET once..." + docker exec "${CONTAINER_NAME}" curl -L -o /dev/null -sf --max-time 30 "${BENCH_DOWNLOAD_URL}" || { + info "ERROR: cannot reach download URL from container" + return 1 + } + fi + + local start_ts end_ts bench_ret + start_ts=$(date +%s.%N) + bench_ret=0 + run_bench_download /tmp/bench-dl-raw.txt timeout 2>/tmp/bench-mitm-dl-stderr.txt || bench_ret=$? + end_ts=$(date +%s.%N) + docker cp "${CONTAINER_NAME}:/tmp/bench-dl-raw.txt" /tmp/bench-mitm-dl-raw-host.txt 2>/dev/null || true + if [[ "$bench_ret" -ne 0 ]]; then + info "Download benchmark failed or timeout (exit $bench_ret); using partial results if any." + fi + if [[ -s /tmp/bench-mitm-dl-stderr.txt ]]; then + head -5 /tmp/bench-mitm-dl-stderr.txt >&2 + fi + if [[ -f /tmp/bench-mitm-dl-raw-host.txt ]]; then + cp /tmp/bench-mitm-dl-raw-host.txt "$out_raw" + else + : > "$out_raw" + fi + + awk -F'\t' '{print $1}' "$out_raw" 2>/dev/null > "/tmp/bench-mitm-${mode}-download-time.txt" + local wall_s + wall_s=$(awk -v s="$start_ts" -v e="$end_ts" 'BEGIN { print e - s }') + echo "$wall_s" > "/tmp/bench-mitm-${mode}-download-wall.txt" + local total_bytes + total_bytes=$(sum_bytes "$out_raw") + echo "$total_bytes" > "/tmp/bench-mitm-${mode}-download-bytes.txt" +} + +run_workloads_for_mode() { + local mode="$1" + if scenario_enabled short; then + run_workload_short "${mode}" + fi + if scenario_enabled download; then + run_workload_download "${mode}" + fi +} + +# Args: mode label (dns_nft | dns_nft_mitm), enable mitm (0|1) +run_phase() { + local mode="$1" + local with_mitm="$2" + info "Phase: dns+nft (mitm=${with_mitm})" + cleanup + mkdir -p "${LOG_HOST_DIR}" + + local docker_env=( + -e OPENSANDBOX_EGRESS_MODE=dns+nft + -e OPENSANDBOX_LOG_OUTPUT="${LOG_CONTAINER_FILE}" + ) + if [[ "$with_mitm" == "1" ]]; then + docker_env+=( + -e OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true + -e OPENSANDBOX_EGRESS_MITMPROXY_PORT="${OPENSANDBOX_EGRESS_MITMPROXY_PORT:-18081}" + ) + fi + + docker run -d --name "${CONTAINER_NAME}" \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + "${docker_env[@]}" \ + -v "${LOG_HOST_DIR}:${LOG_CONTAINER_DIR}" \ + -p "${POLICY_PORT}:18080" \ + "${IMG}" + + for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.5 + done + + # Apply allow policy before any outbound HTTPS probe. Default-deny would block curl until /policy is posted. + local policy_hosts=( "${BENCH_DOMAINS[@]}" ) + if scenario_enabled download && [[ -n "${BENCH_DOWNLOAD_HOST}" ]]; then + local found=0 + for h in "${policy_hosts[@]}"; do + [[ "$h" == "${BENCH_DOWNLOAD_HOST}" ]] && found=1 && break + done + if [[ "$found" -eq 0 ]]; then + policy_hosts+=( "${BENCH_DOWNLOAD_HOST}" ) + info "Policy: allowing download host ${BENCH_DOWNLOAD_HOST}" + fi + fi + + local policy_egress="" + for d in "${policy_hosts[@]}"; do + policy_egress="${policy_egress}{\"action\":\"allow\",\"target\":\"${d}\"}," + done + policy_egress="${policy_egress%,}" + local policy_json="{\"defaultAction\":\"deny\",\"egress\":[${policy_egress}]}" + curl -sf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" -d "${policy_json}" >/dev/null + + copy_url_file_to_container + + if [[ "$with_mitm" == "1" ]]; then + info "Waiting for mitmdump + CA trust (up to ~90s, policy already allows ${BENCH_DOMAINS[0]})..." + local ok=0 + for i in $(seq 1 90); do + if docker exec "${CONTAINER_NAME}" curl -o /dev/null -sf -I --max-time "${CURL_TIMEOUT}" "https://${BENCH_DOMAINS[0]}" 2>/dev/null; then + ok=1 + break + fi + sleep 1 + done + if [[ "$ok" -ne 1 ]]; then + info "WARN: HTTPS to first domain did not succeed in time; benchmark may fail (check logs)." + fi + fi + local stats_out="/tmp/bench-mitm-docker-stats-${mode}.tsv" + : > "${stats_out}" + info "Container metrics (docker stats + /proc/loadavg) -> ${stats_out} (interval ${BENCH_DOCKER_STATS_INTERVAL:-1}s)" + start_docker_stats_log "${stats_out}" + run_workloads_for_mode "${mode}" + stop_docker_stats_log + info "Docker stats log: ${stats_out}" +} + +report_short() { + local n0 n1 avg0 avg1 p50_0 p50_1 p99_0 p99_1 wall0 wall1 rps0 rps1 + read -r n0 avg0 p50_0 p99_0 <<< "$(stats /tmp/bench-mitm-dns_nft-short-total.txt)" + read -r n1 avg1 p50_1 p99_1 <<< "$(stats /tmp/bench-mitm-dns_nft_mitm-short-total.txt)" + wall0=$(cat /tmp/bench-mitm-dns_nft-short-wall.txt 2>/dev/null || echo "0") + wall1=$(cat /tmp/bench-mitm-dns_nft_mitm-short-wall.txt 2>/dev/null || echo "0") + rps0=$(awk -v n="$n0" -v w="$wall0" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') + rps1=$(awk -v n="$n1" -v w="$wall1" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') + + local ov_avg ov_p50 ov_p99 ov_rps + ov_avg=$(awk -v a="$avg1" -v b="$avg0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p50=$(awk -v a="$p50_1" -v b="$p50_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p99=$(awk -v a="$p99_1" -v b="$p99_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_rps=$(awk -v a="$rps1" -v b="$rps0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (b-a)/b*100 : 0 }') + local e2e_loss_ms e2e_loss_pct + read -r e2e_loss_ms e2e_loss_pct <<< "$(calc_e2e_latency_loss "$avg1" "$avg0")" + + echo "" + echo "========== Scenario: short HTTPS (HEAD), high concurrency ==========" + echo "Config: ${ROUNDS} rounds × ${NUM_DOMAINS} URLs × ${BENCH_SHORT_INFLIGHT_PER_DOMAIN} inflight/URL → ${TOTAL_REQUESTS} requests" + echo "" + printf "%-28s %14s %20s %20s %20s\n" "Mode" "Req/s" "Avg(s)" "P50(s)" "P99(s)" + printf "%-28s %14s %20s %20s %20s\n" "dns+nft (no mitm)" "$rps0" "$avg0" "$p50_0" "$p99_0" + printf "%-28s %14s %20s %20s %20s\n" "dns+nft + mitm (transparent)" "$(printf '%.2f(%s%%)' "$rps1" "$ov_rps")" "$(printf '%.3f(%s%%)' "$avg1" "$ov_avg")" "$(printf '%.3f(%s%%)' "$p50_1" "$ov_p50")" "$(printf '%.3f(%s%%)' "$p99_1" "$ov_p99")" + echo "" + echo "E2E latency loss (avg time_total): ${e2e_loss_ms} ms/request (${e2e_loss_pct}%)" + echo "Parentheses vs dns+nft (no mitm): latency +% = slower; Req/s +% = lower throughput." + echo "Artifacts: /tmp/bench-mitm-dns_nft-{short-total,short-wall}.txt and *-dns_nft_mitm-short-*.txt" + echo "==========" +} + +report_download() { + local n0 n1 avg0 avg1 p50_0 p50_1 p99_0 p99_1 wall0 wall1 bytes0 bytes1 mbps0 mbps1 mib0 mib1 + read -r n0 avg0 p50_0 p99_0 <<< "$(stats_download_time /tmp/bench-mitm-dns_nft-download-raw.tsv)" + read -r n1 avg1 p50_1 p99_1 <<< "$(stats_download_time /tmp/bench-mitm-dns_nft_mitm-download-raw.tsv)" + wall0=$(cat /tmp/bench-mitm-dns_nft-download-wall.txt 2>/dev/null || echo "0") + wall1=$(cat /tmp/bench-mitm-dns_nft_mitm-download-wall.txt 2>/dev/null || echo "0") + bytes0=$(cat /tmp/bench-mitm-dns_nft-download-bytes.txt 2>/dev/null || echo "0") + bytes1=$(cat /tmp/bench-mitm-dns_nft_mitm-download-bytes.txt 2>/dev/null || echo "0") + mbps0=$(awk -v b="$bytes0" -v w="$wall0" 'BEGIN { if (w>0 && b>0) printf "%.2f", (b/1048576)/w; else print "0" }') + mbps1=$(awk -v b="$bytes1" -v w="$wall1" 'BEGIN { if (w>0 && b>0) printf "%.2f", (b/1048576)/w; else print "0" }') + mib0=$(awk -v b="$bytes0" 'BEGIN{ printf "%.1f", b/1048576 }') + mib1=$(awk -v b="$bytes1" 'BEGIN{ printf "%.1f", b/1048576 }') + + local ov_avg ov_p50 ov_p99 ov_mbps + ov_avg=$(awk -v a="$avg1" -v b="$avg0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p50=$(awk -v a="$p50_1" -v b="$p50_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_p99=$(awk -v a="$p99_1" -v b="$p99_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') + ov_mbps=$(awk -v a="$mbps1" -v b="$mbps0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (b-a)/b*100 : 0 }') + local e2e_loss_ms e2e_loss_pct + read -r e2e_loss_ms e2e_loss_pct <<< "$(calc_e2e_latency_loss "$avg1" "$avg0")" + + echo "" + echo "========== Scenario: large download (parallel GET) ==========" + echo "URL: ${BENCH_DOWNLOAD_URL}" + echo "Streams: ${BENCH_DOWNLOAD_PARALLEL} parallel × ${BENCH_DOWNLOAD_ROUNDS} round(s)" + echo "" + printf "%-28s %18s %20s %20s %20s %12s\n" "Mode" "Agg MB/s" "Avg(s)/stream" "P50(s)" "P99(s)" "Total MiB" + printf "%-28s %18s %20s %20s %20s %12s\n" "dns+nft (no mitm)" "${mbps0}" "$avg0" "$p50_0" "$p99_0" "$mib0" + printf "%-28s %18s %20s %20s %20s %12s\n" "dns+nft + mitm (transparent)" "$(printf '%.2f(%s%%)' "$mbps1" "$ov_mbps")" "$(printf '%.3f(%s%%)' "$avg1" "$ov_avg")" "$(printf '%.3f(%s%%)' "$p50_1" "$ov_p50")" "$(printf '%.3f(%s%%)' "$p99_1" "$ov_p99")" "$mib1" + echo "" + echo "E2E latency loss (avg time_total): ${e2e_loss_ms} ms/request (${e2e_loss_pct}%)" + echo "Agg MB/s = sum(bytes) / wall clock for the download phase (overlapping streams). Latency % vs no mitm; Agg MB/s % = lower throughput." + echo "Artifacts: /tmp/bench-mitm-*-download-{raw.tsv,wall,bytes}.txt" + echo "==========" +} + +report() { + if scenario_enabled short; then + report_short + fi + if scenario_enabled download; then + report_download + fi + echo "" + echo "Container metrics TSV: /tmp/bench-mitm-docker-stats-dns_nft.tsv and *-dns_nft_mitm.tsv (load1/5/15 + docker stats)" +} + +if [[ -z "${SKIP_BUILD:-}" ]]; then + info "Building image ${IMG}" + docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" > /dev/null 2>&1 +else + info "SKIP_BUILD=1: using existing ${IMG}" +fi + +info "Scenarios: ${BENCH_SCENARIOS}" +run_phase "dns_nft" 0 +run_phase "dns_nft_mitm" 1 +report +info "Done" +cleanup diff --git a/components/egress/tests/egress-in-webhook.sh b/components/egress/tests/egress-in-webhook.sh new file mode 100755 index 0000000..87595b8 --- /dev/null +++ b/components/egress/tests/egress-in-webhook.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +docker run -d --name egress \ + --rm \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + -e OPENSANDBOX_EGRESS_MODE=dns+nft \ + -e OPENSANDBOX_EGRESS_DENY_WEBHOOK=http://:8000 \ + -e OPENSANDBOX_EGRESS_SANDBOX_ID=mytest \ + -p 18080:18080 \ + "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest" + + +sleep 5 +curl -sSf -XPOST "http://127.0.0.1:18080/policy" \ + -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.github.com"},{"action":"deny","target":"10.0.0.0/8"}]}' \ No newline at end of file diff --git a/components/egress/tests/hostname.txt b/components/egress/tests/hostname.txt new file mode 100644 index 0000000..9a4b895 --- /dev/null +++ b/components/egress/tests/hostname.txt @@ -0,0 +1,101 @@ +example.com +example.org +example.net +example.edu +example.io +github.com +github.io +google.com +cloudflare.com +amazon.com +wikipedia.org +mozilla.org +apple.com +microsoft.com +yahoo.com +facebook.com +twitter.com +instagram.com +linkedin.com +reddit.com +stackoverflow.com +npmjs.com +python.org +golang.org +rust-lang.org +docker.com +kubernetes.io +apache.org +gnu.org +kernel.org +ibm.com +oracle.com +openai.com +anthropic.com +stripe.com +slack.com +dropbox.com +spotify.com +netflix.com +twitch.tv +discord.com +zoom.us +medium.com +substack.com +blogger.com +tumblr.com +imgur.com +flickr.com +vimeo.com +soundcloud.com +bandcamp.com +patreon.com +kickstarter.com +etsy.com +ebay.com +craigslist.org +alibaba.com +bing.com +duckduckgo.com +brave.com +opera.com +protonmail.com +fastmail.com +zoho.com +notion.so +trello.com +asana.com +atlassian.com +bitbucket.org +gitlab.com +sourceforge.net +codepen.io +vercel.com +netlify.com +heroku.com +digitalocean.com +linode.com +vultr.com +ovh.com +hetzner.com +scaleway.com +archlinux.org +debian.org +ubuntu.com +fedoraproject.org +opensuse.org +freebsd.org +openbsd.org +mysql.com +mongodb.com +redis.io +elastic.co +nodejs.org +reactjs.org +vuejs.org +svelte.dev +nextjs.org +nuxtjs.org +jquery.com +bootstrap.com +tailwindcss.com diff --git a/components/egress/tests/smoke-dns-upstream-probe.sh b/components/egress/tests/smoke-dns-upstream-probe.sh new file mode 100755 index 0000000..dc0168f --- /dev/null +++ b/components/egress/tests/smoke-dns-upstream-probe.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Smoke: dead upstreams are dropped from the active resolver list after health probes, +# so DNS forwarding does not wait on a black-holed address first. +# +# Requires Docker with --cap-add=NET_ADMIN. The container must reach the "good" resolver +# (default 8.8.8.8). Override with GOOD_DNS if needed. +# +# Example: +# ./smoke-dns-upstream-probe.sh +# GOOD_DNS=1.1.1.1 ./smoke-dns-upstream-probe.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +IMG="opensandbox/egress:local" +containerName="egress-smoke-dns-upstream-probe" +POLICY_PORT=18080 +# Overwritten each run; inspect locally after failure. +EGRESS_LOG_FILE="${SCRIPT_DIR}/egress-smoke-dns-upstream-probe.egress.log" + +# Nothing listens here; probes and forwards should skip it quickly once marked dead. +DEAD_PORT="${DEAD_PORT:-59123}" +GOOD_DNS="${GOOD_DNS:-8.8.8.8}" + +info() { echo "[$(date +%H:%M:%S)] $*"; } + +cleanup() { + docker rm -f "${containerName}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +info "Building image ${IMG}" +docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" + +info "Starting ${containerName} (dead upstream 127.0.0.1:${DEAD_PORT} then ${GOOD_DNS}:53)" +docker run -d --name "${containerName}" \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + -e OPENSANDBOX_EGRESS_MODE=dns \ + -e OPENSANDBOX_EGRESS_RULES='{"defaultAction":"allow"}' \ + -e OPENSANDBOX_EGRESS_DNS_UPSTREAM="127.0.0.1:${DEAD_PORT},${GOOD_DNS}:53" \ + -e OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT=10 \ + -e OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE_INTERVAL_SEC=30 \ + -e OPENSANDBOX_EGRESS_LOG_LEVEL=info \ + -p "${POLICY_PORT}:18080" \ + "${IMG}" + +info "Waiting for policy server..." +for _ in {1..50}; do + if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then + break + fi + sleep 0.5 +done + +pass() { info "PASS: $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +UPSTREAM_ENV="127.0.0.1:${DEAD_PORT},${GOOD_DNS}:53" +info "Configured OPENSANDBOX_EGRESS_DNS_UPSTREAM=${UPSTREAM_ENV}" +info "(dig only shows 127.0.0.1:15353; unreachable upstreams are logged per probe as \"[dns] upstream unreachable:\".)" + +info "Resolving via local DNS proxy (127.0.0.1:15353); if dead upstream were still first, this would take ~upstream-timeout (10s)." +# First probe runs right after Start; wait so active list is pruned before dig. +sleep 3 + +out="$(docker exec "${containerName}" dig @127.0.0.1 -p 15353 +tries=1 +time=25 example.com. 2>&1)" || true +echo "${out}" | tail -n 5 + +qt="$(echo "${out}" | sed -n 's/^;; Query time: \([0-9]*\) msec/\1/p' | head -1)" +if [[ -z "${qt}" ]]; then + fail "could not parse dig Query time (dig failed? output above)" +fi + +# Without active-list pruning, the proxy would try 127.0.0.1:DEAD_PORT first and block ~10s. +if [[ "${qt}" -ge 8000 ]]; then + fail "query took ${qt} msec (expected well under 8000 msec if dead upstream was removed from active list)" +fi + +pass "dig completed in ${qt} msec (dead upstream not blocking)" + +info "Saving egress container logs to ${EGRESS_LOG_FILE}" +docker logs "${containerName}" +docker logs "${containerName}" >"${EGRESS_LOG_FILE}" 2>&1 + +if ! grep -q '\[dns\] upstream probe' "${EGRESS_LOG_FILE}"; then + fail "expected log line containing \"[dns] upstream probe\" (dead upstream probe failure); see ${EGRESS_LOG_FILE}" +fi +pass "egress log contains \"[dns] upstream probe\" (saved in ${EGRESS_LOG_FILE})" + +info "All smoke tests passed." diff --git a/components/egress/tests/smoke-dns.sh b/components/egress/tests/smoke-dns.sh new file mode 100755 index 0000000..159f3db --- /dev/null +++ b/components/egress/tests/smoke-dns.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Simple smoke test using local image. +# Requires Docker with --cap-add=NET_ADMIN available. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +IMG="opensandbox/egress:local" +containerName="egress-smoke-dns" +POLICY_PORT=18080 +POLICY_FILE_HOST="$(mktemp)" + +info() { echo "[$(date +%H:%M:%S)] $*"; } + +cleanup() { + docker rm -f "${containerName}" >/dev/null 2>&1 || true + rm -f "${POLICY_FILE_HOST}" 2>/dev/null || true +} +trap cleanup EXIT + +info "Building image ${IMG}" +docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" + +info "Writing policy JSON to ${POLICY_FILE_HOST} (default deny; allow *.github.com)" +printf '%s\n' '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.github.com"}]}' >"${POLICY_FILE_HOST}" + +info "Starting containerName (policy from file; env rules intentionally wrong to assert file wins)" +docker run -d --name "${containerName}" \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + -e OPENSANDBOX_EGRESS_MODE=dns \ + -e OPENSANDBOX_EGRESS_POLICY_FILE=/etc/opensandbox/egress-policy.json \ + -e OPENSANDBOX_EGRESS_RULES='{"defaultAction":"allow"}' \ + -v "${POLICY_FILE_HOST}:/etc/opensandbox/egress-policy.json" \ + -p ${POLICY_PORT}:18080 \ + "${IMG}" + +info "Waiting for policy server..." +for i in {1..50}; do + if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then + break + fi + sleep 0.5 +done + +run_in_app() { + docker run --rm --network container:"${containerName}" curlimages/curl "$@" +} + +pass() { info "PASS: $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +info "Test: denied domain should fail (google.com)" +if run_in_app -I https://google.com --max-time 5 >/dev/null 2>&1; then + fail "google.com should be blocked" +else + pass "google.com blocked" +fi + +info "Test: allowed domain should succeed (api.github.com)" +run_in_app -I https://api.github.com --max-time 10 >/dev/null 2>&1 || fail "api.github.com should succeed" +pass "api.github.com allowed" + +info "All smoke tests passed." \ No newline at end of file diff --git a/components/egress/tests/smoke-dynamic-ip.sh b/components/egress/tests/smoke-dynamic-ip.sh new file mode 100755 index 0000000..104a17e --- /dev/null +++ b/components/egress/tests/smoke-dynamic-ip.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Smoke test: default deny + domain allow in dns+nft mode. +# Verifies that allowing a domain causes its resolved IP to be added to nft (dynamic IP), +# so that curl to that domain succeeds without static IP/CIDR in policy. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +IMG="opensandbox/egress:local" +containerName="egress-smoke-dynamic-ip" +POLICY_PORT=18080 + +info() { echo "[$(date +%H:%M:%S)] $*"; } + +cleanup() { + docker rm -f "${containerName}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +info "Building image ${IMG}" +docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" + +info "Starting sidecar (dns+nft)" +docker run -d --name "${containerName}" \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + -e OPENSANDBOX_EGRESS_MODE=dns+nft \ + -e OPENSANDBOX_EGRESS_DNS_UPSTREAM=8.8.8.8,8.8.4.4 \ + -p ${POLICY_PORT}:18080 \ + "${IMG}" + +info "Waiting for policy server..." +for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then + break + fi + sleep 0.5 +done + +info "Pushing policy (default deny; allow google.com only)" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"google.com"}]}' + +run_in_app() { + docker run --rm --network container:"${containerName}" curlimages/curl "$@" +} + +pass() { info "PASS: $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +info "Test: allowed domain (google.com) should succeed via dynamic IP" +run_in_app -I https://google.com --max-time 20 >/dev/null 2>&1 || fail "google.com should succeed (DNS allow + dynamic IP in nft)" +pass "google.com allowed" + +info "Test: denied domain (api.github.com) should fail" +if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then + fail "api.github.com should be blocked" +else + pass "api.github.com blocked" +fi + +info "Test: denied IP (1.1.1.1) should fail" +if run_in_app -I 1.1.1.1 --max-time 8 >/dev/null 2>&1; then + fail "1.1.1.1 should be blocked" +else + pass "1.1.1.1 blocked" +fi + +info "All smoke tests (dynamic IP) passed." diff --git a/components/egress/tests/smoke-nft.sh b/components/egress/tests/smoke-nft.sh new file mode 100755 index 0000000..ff704f7 --- /dev/null +++ b/components/egress/tests/smoke-nft.sh @@ -0,0 +1,218 @@ +#!/bin/bash + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Simple smoke test using local image. +# Requires Docker with --cap-add=NET_ADMIN available. +# Optional upstream failover check: tests/smoke-dns-upstream-failover.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +IMG="opensandbox/egress:local" +containerName="egress-smoke-nft" +POLICY_PORT=18080 +ALWAYS_RULES_DIR_HOST="$(mktemp -d -t egress-always-rules.XXXXXX)" + +info() { echo "[$(date +%H:%M:%S)] $*"; } + +cleanup() { + docker rm -f "${containerName}" >/dev/null 2>&1 || true + rm -rf "${ALWAYS_RULES_DIR_HOST}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +info "Building image ${IMG}" +docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" + +info "Starting containerName" +docker run -d --name "${containerName}" \ + --cap-add=NET_ADMIN \ + --sysctl net.ipv6.conf.all.disable_ipv6=1 \ + --sysctl net.ipv6.conf.default.disable_ipv6=1 \ + -e OPENSANDBOX_EGRESS_MODE=dns+nft \ + -e OPENSANDBOX_EGRESS_DNS_UPSTREAM=8.8.8.8,8.8.4.4 \ + -p ${POLICY_PORT}:18080 \ + -v "${ALWAYS_RULES_DIR_HOST}:/var/egress/rules" \ + "${IMG}" + +info "Waiting for policy server..." +for i in {1..50}; do + if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then + break + fi + sleep 0.5 +done + +info "Pushing policy (allow by default; deny github.com & 10.0.0.0/8)" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.github.com"},{"action":"deny","target":"10.0.0.0/8"}]}' + +run_in_app() { + docker run --rm --network container:"${containerName}" curlimages/curl "$@" +} + +pass() { info "PASS: $*"; } +fail() { echo "FAIL: $*" >&2; exit 1; } + +wait_until_always_flip() { + local timeout_sec="${1:-90}" + local elapsed=0 + local step_sec=2 + while [ "${elapsed}" -lt "${timeout_sec}" ]; do + # Expected after refresh: + # - deny.always blocks api.github.com + # - allow.always allows www.mozilla.org + if ! run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1 \ + && run_in_app -I https://www.mozilla.org --max-time 8 >/dev/null 2>&1; then + return 0 + fi + sleep "${step_sec}" + elapsed=$((elapsed + step_sec)) + done + return 1 +} + +info "Test: allowed domain should succeed (google.com)" +run_in_app -I https://google.com --max-time 20 >/dev/null 2>&1 || fail "google.com should succeed" +pass "google.com allowed" + +info "Test: denied domain should fail (api.github.com)" +if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then + fail "api.github.com should be blocked" +else + pass "api.github.com blocked" +fi + +info "Test: allowed IP should succeed (1.1.1.1)" +run_in_app -I https://1.1.1.1 --max-time 10 >/dev/null 2>&1 || fail "1.1.1.1 should succeed" +pass "1.1.1.1 allowed" + +info "Test: denied CIDR should fail (10.0.0.1)" +if run_in_app -I http://10.0.0.1 --max-time 5 >/dev/null 2>&1; then + fail "10.0.0.1 should be blocked" +else + pass "10.0.0.1 blocked" +fi + +info "Test: DoT (853) should be blocked" +if run_in_app -k https://1.1.1.1:853 --max-time 5 >/dev/null 2>&1; then + fail "DoT 853 should be blocked" +else + pass "DoT 853 blocked" +fi + +info "Rules update: wildcard deny -> patch allow specific (dns+nft)" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.cloudflare.com"}]}' + +info "Test: www.cloudflare.com should be blocked initially (deny via wildcard)" +if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then + fail "www.cloudflare.com should be blocked before patch" +else + pass "www.cloudflare.com blocked before patch" +fi + +info "Patching allow for www.cloudflare.com (specific should override earlier deny)" +curl -sSf -XPATCH "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '[{"action":"allow","target":"www.cloudflare.com"}]' + +info "Test: www.cloudflare.com should be allowed after patch" +run_in_app -I https://www.cloudflare.com --max-time 20 >/dev/null 2>&1 || fail "www.cloudflare.com should succeed after patch" +pass "www.cloudflare.com allowed after patch" + +info "Rules update: wildcard allow -> patch deny specific (dns+nft)" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.mozilla.org"}]}' + +info "Test: www.mozilla.org should be allowed initially (allow via wildcard)" +run_in_app -I https://www.mozilla.org --max-time 20 >/dev/null 2>&1 || fail "www.mozilla.org should succeed before patch" +pass "www.mozilla.org allowed before patch" + +info "Patching deny for www.mozilla.org (specific should override earlier allow)" +curl -sSf -XPATCH "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '[{"action":"deny","target":"www.mozilla.org"}]' + +info "Test: www.mozilla.org should be blocked after patch" +if run_in_app -I https://www.mozilla.org --max-time 8 >/dev/null 2>&1; then + fail "www.mozilla.org should be blocked after patch" +else + pass "www.mozilla.org blocked after patch" +fi + +info "DELETE: deny two hosts, then delete one rule" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"api.github.com"},{"action":"deny","target":"www.cloudflare.com"}]}' + +info "Test: both hosts should be blocked before delete" +if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then + fail "api.github.com should be blocked before delete" +fi +if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then + fail "www.cloudflare.com should be blocked before delete" +fi +pass "both hosts blocked before delete" + +info "Deleting api.github.com rule" +curl -sSf -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '["api.github.com"]' + +info "Test: api.github.com allowed, www.cloudflare.com still blocked after delete" +run_in_app -I https://api.github.com --max-time 20 >/dev/null 2>&1 || fail "api.github.com should be allowed after delete" +pass "api.github.com allowed after delete" +if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then + fail "www.cloudflare.com should remain blocked after delete" +fi +pass "www.cloudflare.com still blocked" + +info "Deleting non-existent target (idempotent)" +resp="$(curl -sSf -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" -d '["nonexistent.com"]')" +if echo "${resp}" | grep -q '"no matching targets found"'; then + pass "idempotent delete returns no matching targets found" +else + fail "expected no matching targets found, got: ${resp}" +fi + +info "Deleting with empty body (expect 400)" +http_code="$(curl -s -o /dev/null -w '%{http_code}' -XDELETE "http://127.0.0.1:${POLICY_PORT}/policy" -d '')" +if [ "${http_code}" = "400" ]; then + pass "empty body returns 400" +else + fail "empty body should return 400, got ${http_code}" +fi + +info "Always-rule dynamic check (single transition)" +curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ + -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"api.github.com"}]}' + +info "Baseline before file update: github allowed, mozilla blocked" +run_in_app -I https://api.github.com --max-time 20 >/dev/null 2>&1 || fail "api.github.com should be allowed before always file update" +if run_in_app -I https://www.mozilla.org --max-time 8 >/dev/null 2>&1; then + fail "www.mozilla.org should be blocked before always file update" +fi +pass "baseline verified" + +printf '%s\n' '*.github.com' > "${ALWAYS_RULES_DIR_HOST}/deny.always" +printf '%s\n' 'www.mozilla.org' > "${ALWAYS_RULES_DIR_HOST}/allow.always" +if wait_until_always_flip 90; then + pass "always files reloaded (github blocked, mozilla allowed)" +else + fail "always file update did not take effect in time" +fi + +info "All smoke tests passed." \ No newline at end of file diff --git a/components/egress/tests/test_mitmscripts_system.py b/components/egress/tests/test_mitmscripts_system.py new file mode 100644 index 0000000..74b8527 --- /dev/null +++ b/components/egress/tests/test_mitmscripts_system.py @@ -0,0 +1,701 @@ +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import types +import unittest +from pathlib import Path +from typing import Any + + +class _Log: + def __init__(self) -> None: + self.messages: list[str] = [] + + def warn(self, message: str) -> None: + self.messages.append(message) + + def info(self, message: str) -> None: + self.messages.append(message) + + +class _Headers: + def __init__(self, values: dict[str, str]) -> None: + self._values = dict(values) + + def get(self, name: str, default: str = "") -> str: + for key, value in self._values.items(): + if key.lower() == name.lower(): + return value + return default + + def items(self) -> list[tuple[str, str]]: + return list(self._values.items()) + + def __setitem__(self, name: str, value: str) -> None: + self._values[name] = value + + def __contains__(self, name: str) -> bool: + return any(key.lower() == name.lower() for key in self._values) + + def __delitem__(self, name: str) -> None: + for key in list(self._values): + if key.lower() == name.lower(): + del self._values[key] + return + + +class _Request: + def __init__(self) -> None: + self.pretty_host = "code.example.com" + self.host = "code.example.com" + self.port = 443 + self.scheme = "https" + self.method = "GET" + self.path = "/api/v8/projects" + self.headers = _Headers({}) + self.raw_content: bytes | None = None + self.content = b"" + + +class _Response: + def __init__(self) -> None: + self.headers = _Headers( + { + "content-type": "application/json", + "x-token-echo": "secret-token", + } + ) + self.stream = False + self.body = "upstream body includes secret-token" + self.set_text_called = False + + def get_text(self, strict: bool = False) -> str: + return self.body + + def set_text(self, value: str) -> None: + self.set_text_called = True + self.body = value + + +class _Flow: + def __init__(self) -> None: + self.request = _Request() + self.response = _Response() + self.metadata: dict[str, Any] = {} + + +def _load_system_module() -> Any: + mitmproxy = types.ModuleType("mitmproxy") + mitmproxy.ctx = types.SimpleNamespace(log=_Log(), options=types.SimpleNamespace(ignore_hosts=[])) + def _make_response(status: int, body: bytes = b"", headers: dict | None = None): + resp = _Response() + resp.status_code = status + resp.body = body.decode("utf-8") if isinstance(body, bytes) else body + if headers: + resp.headers = _Headers(headers) + return resp + + mitmproxy.http = types.SimpleNamespace(HTTPFlow=object, Response=types.SimpleNamespace(make=_make_response)) + mitmproxy_tls = types.ModuleType("mitmproxy.tls") + mitmproxy_tls.ClientHelloData = object + + sys.modules["mitmproxy"] = mitmproxy + sys.modules["mitmproxy.tls"] = mitmproxy_tls + + path = Path(__file__).parents[1] / "mitmscripts" / "system.py" + spec = importlib.util.spec_from_file_location("opensandbox_egress_system_addon", path) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class SystemAddonRedactionTest(unittest.TestCase): + def test_load_active_vault_reads_unix_socket(self) -> None: + system = _load_system_module() + calls: list[tuple[str, Any, Any]] = [] + + class FakeResponse: + status = 200 + + def read(self) -> bytes: + return json.dumps( + { + "revision": 7, + "bindings": [ + { + "name": "gitlab-api", + "headers": [ + {"name": "Private-Token", "value": "secret-token"} + ], + } + ], + "redactions": ["secret-token"], + } + ).encode("utf-8") + + class FakeConnection: + def __init__(self, socket_path: str, timeout: float) -> None: + calls.append(("init", socket_path, timeout)) + + def request(self, method: str, path: str) -> None: + calls.append(("request", method, path)) + + def getresponse(self) -> FakeResponse: + calls.append(("getresponse", None, None)) + return FakeResponse() + + def close(self) -> None: + calls.append(("close", None, None)) + + old_socket = os.environ.get(system.CREDENTIAL_PROXY_SOCKET_ENV) + old_connection = system.UnixSocketHTTPConnection + os.environ[system.CREDENTIAL_PROXY_SOCKET_ENV] = "/tmp/active.sock" + system.UnixSocketHTTPConnection = FakeConnection + try: + vault = system._load_active_vault() + finally: + system.UnixSocketHTTPConnection = old_connection + if old_socket is None: + os.environ.pop(system.CREDENTIAL_PROXY_SOCKET_ENV, None) + else: + os.environ[system.CREDENTIAL_PROXY_SOCKET_ENV] = old_socket + + self.assertIsNotNone(vault) + assert vault is not None + self.assertEqual(7, vault.revision) + self.assertEqual(["secret-token"], vault.redactions) + self.assertEqual(("init", "/tmp/active.sock", 0.25), calls[0]) + self.assertEqual(("request", "GET", system.ACTIVE_VAULT_PATH), calls[1]) + self.assertEqual(("close", None, None), calls[-1]) + + def test_request_injection_log_does_not_include_secret_value(self) -> None: + system = _load_system_module() + flow = _Flow() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "gitlab-api", + "match": { + "hosts": ["code.example.com"], + "methods": ["GET"], + "paths": ["/api/v8/*"], + }, + "headers": [{"name": "Private-Token", "value": "secret-token"}], + } + ], + ["secret-token"], + ) + + system.request(flow) + + self.assertEqual("secret-token", flow.request.headers.get("Private-Token")) + self.assertNotIn("secret-token", "\n".join(system.ctx.log.messages)) + + def test_responseheaders_redacts_headers_without_body_hook(self) -> None: + system = _load_system_module() + flow = _Flow() + flow.metadata[system.FLOW_REDACTIONS_KEY] = ["secret-token"] + + system.responseheaders(flow) + + self.assertEqual("[REDACTED]", flow.response.headers.get("x-token-echo")) + self.assertEqual("upstream body includes secret-token", flow.response.body) + self.assertFalse(flow.response.set_text_called) + self.assertFalse(hasattr(system, "response")) + + def test_responseheaders_uses_injected_flow_redactions(self) -> None: + system = _load_system_module() + flow = _Flow() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "gitlab-api", + "match": {"hosts": ["code.example.com"]}, + "headers": [{"name": "Private-Token", "value": "old-secret"}], + } + ], + ["old-secret"], + ) + + system.request(flow) + system._load_active_vault = lambda: system.ActiveVault(2, [], ["new-secret"]) + flow.response.headers["x-token-echo"] = "old-secret" + system.responseheaders(flow) + + self.assertEqual("[REDACTED]", flow.response.headers.get("x-token-echo")) + + def test_responseheaders_redacts_longer_values_first(self) -> None: + system = _load_system_module() + flow = _Flow() + flow.response.headers["x-token-echo"] = "prefix token-abc suffix" + flow.metadata[system.FLOW_REDACTIONS_KEY] = ["token", "token-abc"] + + system.responseheaders(flow) + + self.assertEqual("prefix [REDACTED] suffix", flow.response.headers.get("x-token-echo")) + + +class SystemAddonSubstitutionTest(unittest.TestCase): + def _make_system_with_substitutions(self): + system = _load_system_module() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "placeholder-api", + "match": { + "hosts": ["code.example.com"], + "methods": ["GET", "POST"], + "paths": ["/lookup", "/tenants/*", "/token", "/form"], + }, + "substitutions": [ + { + "placeholder": "__query_secret__", + "value": "query secret+value", + "in": ["query"], + }, + { + "placeholder": "__tenant_id__", + "value": "tenant 42", + "in": ["path"], + }, + { + "placeholder": "__body_secret__", + "value": 'body "secret" \\ value', + "in": ["body"], + }, + { + "placeholder": "__form_secret__", + "value": "form secret+value", + "in": ["body"], + }, + ], + } + ], + [ + "__query_secret__", + "query secret+value", + "__tenant_id__", + "tenant 42", + "__body_secret__", + 'body "secret" \\ value', + "__form_secret__", + "form secret+value", + ], + ) + return system + + def test_query_substitution_url_encodes_value(self) -> None: + system = self._make_system_with_substitutions() + flow = _Flow() + flow.response = None + flow.request.path = "/lookup?api_key=__query_secret__&static=1" + + system.request(flow) + + self.assertEqual("/lookup?api_key=query%20secret%2Bvalue&static=1", flow.request.path) + self.assertIn("query secret+value", flow.metadata[system.FLOW_REDACTIONS_KEY]) + self.assertNotIn("query secret+value", "\n".join(system.ctx.log.messages)) + + def test_path_substitution_rewrites_only_path(self) -> None: + system = self._make_system_with_substitutions() + flow = _Flow() + flow.response = None + flow.request.path = "/tenants/__tenant_id__/items?tenant=__tenant_id__" + + system.request(flow) + + self.assertEqual("/tenants/tenant%2042/items?tenant=__tenant_id__", flow.request.path) + + def test_json_body_substitution_escapes_string_value_and_updates_length(self) -> None: + system = self._make_system_with_substitutions() + flow = _Flow() + flow.response = None + flow.request.method = "POST" + flow.request.path = "/token" + flow.request.headers["content-type"] = "application/json" + flow.request.headers["transfer-encoding"] = "chunked" + flow.request.content = b'{"client_secret":"__body_secret__"}' + + system.request(flow) + + body = flow.request.content.decode("utf-8") + self.assertEqual({"client_secret": 'body "secret" \\ value'}, json.loads(body)) + self.assertEqual(str(len(flow.request.content)), flow.request.headers.get("content-length")) + self.assertEqual("", flow.request.headers.get("transfer-encoding")) + + def test_structured_json_body_substitution_escapes_string_value(self) -> None: + system = self._make_system_with_substitutions() + flow = _Flow() + flow.response = None + flow.request.method = "POST" + flow.request.path = "/token" + flow.request.headers["content-type"] = "application/merge-patch+json; charset=utf-8" + flow.request.content = b'{"client_secret":"__body_secret__"}' + + system.request(flow) + + body = flow.request.content.decode("utf-8") + self.assertEqual({"client_secret": 'body "secret" \\ value'}, json.loads(body)) + + def test_form_body_substitution_url_encodes_value(self) -> None: + system = self._make_system_with_substitutions() + flow = _Flow() + flow.response = None + flow.request.method = "POST" + flow.request.path = "/form" + flow.request.headers["content-type"] = "application/x-www-form-urlencoded" + flow.request.content = b"secret=__form_secret__" + + system.request(flow) + + self.assertEqual(b"secret=form+secret%2Bvalue", flow.request.content) + + def test_body_substitution_does_not_rewrite_inserted_values(self) -> None: + system = _load_system_module() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "nested-body", + "match": { + "hosts": ["code.example.com"], + "methods": ["POST"], + "paths": ["/token"], + }, + "substitutions": [ + { + "placeholder": "__a__", + "value": "prefix __b__", + "in": ["body"], + }, + { + "placeholder": "__b__", + "value": "secret-b", + "in": ["body"], + }, + ], + } + ], + ["__a__", "prefix __b__", "__b__", "secret-b"], + ) + flow = _Flow() + flow.response = None + flow.request.method = "POST" + flow.request.path = "/token" + flow.request.headers["content-type"] = "text/plain" + flow.request.content = b"first=__a__&second=__b__" + + system.request(flow) + + self.assertEqual(b"first=prefix __b__&second=secret-b", flow.request.content) + + def test_header_substitution_does_not_rewrite_injected_headers(self) -> None: + system = _load_system_module() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "header-substitution", + "match": { + "hosts": ["code.example.com"], + "methods": ["GET"], + "paths": ["/lookup"], + }, + "headers": [ + {"name": "Authorization", "value": "Bearer __header_secret__"} + ], + "substitutions": [ + { + "placeholder": "__header_secret__", + "value": "substituted-secret", + "in": ["header"], + }, + ], + } + ], + ["__header_secret__", "substituted-secret"], + ) + flow = _Flow() + flow.response = None + flow.request.path = "/lookup" + flow.request.headers["X-Template"] = "client __header_secret__" + + system.request(flow) + + self.assertEqual("client substituted-secret", flow.request.headers.get("X-Template")) + self.assertEqual("Bearer __header_secret__", flow.request.headers.get("Authorization")) + + def test_compressed_body_substitution_is_skipped(self) -> None: + system = self._make_system_with_substitutions() + flow = _Flow() + flow.response = None + flow.request.method = "POST" + flow.request.path = "/token" + flow.request.headers["content-type"] = "application/json" + flow.request.headers["content-encoding"] = "gzip" + flow.request.content = b'{"client_secret":"__body_secret__"}' + + system.request(flow) + + self.assertEqual(b'{"client_secret":"__body_secret__"}', flow.request.content) + self.assertNotIn(system.FLOW_REDACTIONS_KEY, flow.metadata) + self.assertIn("substitution miss", "\n".join(system.ctx.log.messages)) + + def test_rejected_path_substitution_log_does_not_include_secret_value(self) -> None: + system = _load_system_module() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "path-secret", + "match": { + "hosts": ["code.example.com"], + "methods": ["GET"], + "paths": ["/tenants/*"], + }, + "substitutions": [ + { + "placeholder": "__tenant_id__", + "value": "tenant/secret", + "in": ["path"], + }, + ], + } + ], + ["__tenant_id__", "tenant/secret", "tenant%2Fsecret"], + ) + flow = _Flow() + flow.request.path = "/tenants/__tenant_id__/items" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + logs = "\n".join(system.ctx.log.messages) + self.assertIn("path=[REDACTED]", logs) + self.assertNotIn("tenant/secret", logs) + self.assertNotIn("tenant%2Fsecret", logs) + + def test_path_substitution_rejects_nested_encoded_separator(self) -> None: + system = _load_system_module() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "path-secret", + "match": { + "hosts": ["code.example.com"], + "methods": ["GET"], + "paths": ["/tenants/*"], + }, + "substitutions": [ + { + "placeholder": "__tenant_id__", + "value": "tenant%2Fsecret", + "in": ["path"], + }, + ], + } + ], + ["__tenant_id__", "tenant%2Fsecret", "tenant%252Fsecret"], + ) + flow = _Flow() + flow.request.path = "/tenants/__tenant_id__/items" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + + +class SystemAddonPathTraversalTest(unittest.TestCase): + """Regression tests for CVE-like path traversal credential injection bypass.""" + + def _make_system_with_vault(self): + system = _load_system_module() + system._load_active_vault = lambda: system.ActiveVault( + 1, + [ + { + "name": "gitlab-api", + "match": { + "hosts": ["code.example.com"], + "methods": ["GET"], + "paths": ["/api/v8/projects/123/*"], + }, + "headers": [{"name": "Private-Token", "value": "secret-token"}], + } + ], + ["secret-token"], + ) + return system + + def test_dot_dot_traversal_rejected(self) -> None: + """Raw .. traversal escaping path scope must be rejected with 403.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/../456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_encoded_dot_dot_traversal_rejected(self) -> None: + """%2e%2e encoded traversal must be rejected with 403.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/%2e%2e/456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_mixed_case_encoded_dot_dot_rejected(self) -> None: + """%2E%2e mixed-case encoded traversal must be rejected.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/%2E%2e/456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_encoded_slash_rejected(self) -> None: + """%2f encoded path separator must be rejected.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123%2f..%2f456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_dot_dot_in_query_string_not_rejected(self) -> None: + """.. in query string is harmless and should not trigger rejection.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/variables?ref=../../main" + + system.request(flow) + + # Should inject credential normally (path matches the binding). + self.assertEqual("secret-token", flow.request.headers.get("Private-Token")) + + def test_normal_path_within_scope_injects_credential(self) -> None: + """Normal path within scope still receives credential injection.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/variables" + + system.request(flow) + + self.assertEqual("secret-token", flow.request.headers.get("Private-Token")) + + def test_normal_path_outside_scope_no_injection(self) -> None: + """Normal path outside scope does not receive credential injection.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/456/variables" + + system.request(flow) + + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_dot_dot_substring_not_rejected(self) -> None: + """'..' not as a complete segment (e.g. '/.../') must NOT be blocked.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/.../data" + system.request(flow) + self.assertEqual("secret-token", flow.request.headers.get("Private-Token")) + + def test_dot_dot_metadata_path_not_rejected(self) -> None: + """``/..metadata`` is not a traversal segment.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/..metadata" + system.request(flow) + self.assertEqual("secret-token", flow.request.headers.get("Private-Token")) + + def test_encoded_backslash_rejected(self) -> None: + """%5c encoded backslash must be rejected.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/%5c..%5c456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_raw_backslash_rejected(self) -> None: + """Raw backslash in path must be rejected.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123\\..\\456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_double_encoded_dot_dot_rejected(self) -> None: + """Double percent-encoded traversal (%252e%252e) must be rejected.""" + system = self._make_system_with_vault() + flow = _Flow() + flow.request.path = "/api/v8/projects/123/%252e%252e/456/variables" + + system.request(flow) + + self.assertIsNotNone(flow.response) + self.assertEqual(403, flow.response.status_code) + self.assertNotIn("Private-Token", flow.request.headers._values) + + def test_no_vault_active_allows_dot_dot_through(self) -> None: + """When no vault is active, ambiguous paths are not blocked (no credential risk).""" + system = _load_system_module() + system._load_active_vault = lambda: None + flow = _Flow() + flow.request.path = "/api/v8/projects/123/../456/variables" + + system.request(flow) + + # No response set means the request passes through unmodified. + # (flow.response is the pre-initialized _Response, not a 403) + self.assertNotIn("Private-Token", flow.request.headers._values) + + +if __name__ == "__main__": + unittest.main() diff --git a/components/egress/tests/webhook-server.py b/components/egress/tests/webhook-server.py new file mode 100644 index 0000000..9826bbf --- /dev/null +++ b/components/egress/tests/webhook-server.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Lightweight HTTP server to receive OPENSANDBOX_EGRESS_DENY_WEBHOOK callbacks. + +Config: +- WEBHOOK_HOST: listen address (default 0.0.0.0) +- WEBHOOK_PORT: listen port (default 8000) +- WEBHOOK_PATH: webhook path (default /) + +Run: + python webhook_server.py +Then point OPENSANDBOX_EGRESS_DENY_WEBHOOK to http://: +""" + +import http.server +import json +import os +import socketserver +from datetime import datetime + +HOST = os.getenv("WEBHOOK_HOST", "0.0.0.0") +PORT = int(os.getenv("WEBHOOK_PORT", "8000")) +PATH = os.getenv("WEBHOOK_PATH", "/") + + +class WebhookHandler(http.server.BaseHTTPRequestHandler): + def _send(self, code: int = 200, body: str = "ok") -> None: + self.send_response(code) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(body.encode("utf-8")) + + def do_POST(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API) + # Only allow the configured path + if self.path != PATH: + self._send(404, "not found") + return + + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) if length else b"" + + payload = raw.decode("utf-8", errors="replace") + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + parsed = None + + # Log request info for debugging + print(f"\n[{datetime.utcnow().isoformat()}Z] Received webhook") + print(f"Path: {self.path}") + print(f"Headers: {dict(self.headers)}") + print(f"Raw body: {payload}") + if parsed is not None: + print("Parsed JSON:") + print(json.dumps(parsed, indent=2)) + + self._send(200, "received") + + # Silence default logging to reduce noise + def log_message(self, *args) -> None: + return + + +def main() -> None: + with socketserver.TCPServer((HOST, PORT), WebhookHandler) as httpd: + print(f"Listening on http://{HOST}:{PORT}{PATH} ...") + httpd.serve_forever() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/components/execd/.golangci.yml b/components/execd/.golangci.yml new file mode 100644 index 0000000..fb8d85b --- /dev/null +++ b/components/execd/.golangci.yml @@ -0,0 +1,321 @@ +run: + skip-dirs: + - vendor + - tests + - scripts + skip-files: + - .*/zz_generated.deepcopy.go + - .*/mock/*.go + tests: false + timeout: 10m +linters-settings: + funlen: + lines: 500 + statements: 200 + gocyclo: + min-complexity: 40 + gosimple: + checks: ["S1019", "S1002"] + staticcheck: + checks: ["SA4006"] + govet: + enable: + - asmdecl + - assign + - atomic + - atomicalign + - bools + - buildtag + - cgocall + - copylocks + - deepequalerrors + - errorsas + - findcall + - framepointer + - httpresponse + - ifaceassert + - lostcancel + - nilfunc + - nilness + - reflectvaluecompare + - shift + - sigchanyzer + - sortslice + - stdmethods + - stringintconv + - testinggoroutine + - tests + - unmarshal + - unreachable + - unsafeptr + - unusedresult + - printf + disable: + - composites + - loopclosure + - fieldalignment + - shadow + - structtag + - unusedwrite + errcheck: + exclude-functions: + - flag.Set + - os.Setenv + - os.Unsetenv + - logger.Sync + - fmt.Fprintf + - fmt.Fprintln + - (io.Closer).Close + - (io.ReadCloser).Close + - (k8s.io/client-go/tools/cache.SharedInformer).AddEventHandler + nestif: + min-complexity: 32 + goconst: + # Minimal length of string constant. + # Default: 3 + min-len: 3 + # Minimum occurrences of constant string count to trigger issue. + # Default: 3 + min-occurrences: 3 + # Ignore test files. + # Default: false + ignore-tests: true + match-constant: false + numbers: true + min: 2 + max: 10 + ignore-calls: true + gosec: + includes: + - G101 # Look for hard coded credentials + - G102 # Bind to all interfaces + - G103 # Audit the use of unsafe block + - G104 # Audit errors not checked + - G106 # Audit the use of ssh.InsecureIgnoreHostKey + - G107 # Url provided to HTTP request as taint input + - G108 # Profiling endpoint automatically exposed on /debug/pprof + - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 + - G110 # Potential DoS vulnerability via decompression bomb + - G111 # Potential directory traversal + - G112 # Potential slowloris attack + - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) + # - G114 # Use of net/http serve function that has no support for setting timeouts + - G201 # SQL query construction using format string + - G202 # SQL query construction using string concatenation + - G203 # Use of unescaped data in HTML templates + #- G204 # Audit use of command execution + - G301 # Poor file permissions used when creating a directory + - G302 # Poor file permissions used with chmod + - G303 # Creating tempfile using a predictable path + - G304 # File path provided as taint input + - G305 # File traversal when extracting zip/tar archive + - G306 # Poor file permissions used when writing to a new file + - G307 # Deferring a method which returns an error + #- G401 # Detect the usage of DES, RC4, MD5 or SHA1 + - G402 # Look for bad TLS connection settings + - G403 # Ensure minimum RSA key length of 2048 bits + - G404 # Insecure random number source (rand) + #- G501 # Import blocklist: crypto/md5 + - G502 # Import blocklist: crypto/des + - G503 # Import blocklist: crypto/rc4 + - G504 # Import blocklist: net/http/cgi + - G505 # Import blocklist: crypto/sha1 + - G601 # Implicit memory aliasing of items from a range statement + # Exclude generated files + # Default: false + exclude-generated: true + # Filter out the issues with a lower severity than the given value. + # Valid options are: low, medium, high. + # Default: low + severity: medium + # Filter out the issues with a lower confidence than the given value. + # Valid options are: low, medium, high. + # Default: low + confidence: medium + # Concurrency value. + # Default: the number of logical CPUs usable by the current process. + concurrency: 12 + # To specify the configuration of rules. + config: + # Globals are applicable to all rules. + global: + nosec: true + show-ignored: true + audit: true + G101: + # Regexp pattern for variables and constants to find. + # Default: "(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred" + pattern: "(?i)example" + # If true, complain about all cases (even with low entropy). + # Default: false + ignore_entropy: false + # Maximum allowed entropy of the string. + # Default: "80.0" + entropy_threshold: "80.0" + per_char_threshold: "3.0" + truncate: "32" + G104: + fmt: + - Fscanf + G111: + # Regexp pattern to find potential directory traversal. + # Default: "http\\.Dir\\(\"\\/\"\\)|http\\.Dir\\('\\/'\\)" + pattern: "custom\\.Dir\\(\\)" + # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll + # Default: "0750" + G301: "0750" + # Maximum allowed permissions mode for os.OpenFile and os.Chmod + # Default: "0600" + G302: "0600" + # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile + # Default: "0600" + G306: "0600" + nilnil: + checked-types: + - ptr + - map + - chan + depguard: + rules: + prevent_unmaintained_packages: + list-mode: lax # allow unless explicitely denied + files: + - $all + - "!$test" + allow: + - $gostd + - path/filepath + deny: + - pkg: io/ioutil + desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" + - pkg: path + desc: "replaced by cross-platform package path/filepath" + gci: + # Section configuration to compare against. + # Section names are case-insensitive and may contain parameters in (). + # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`, + # If `custom-order` is `true`, it follows the order of `sections` option. + # Default: ["standard", "default"] + sections: + - standard # Standard section: captures all standard packages. + - default # Default section: contains all imports that could not be matched to another section type.: + - prefix(github.com/org/project) # Custom section: groups all imports with the specified Prefix. + - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. + - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. + - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. + # Skip generated files. + # Default: true + skip-generated: true + # Enable custom order of sections. + # If `true`, make the section order the same as the order of `sections`. + # Default: false + custom-order: true + # Drops lexical ordering for custom sections. + # Default: false + no-lex-order: true + forbidigo: + forbid: + # Forbid spew Dump, whether it is called as function or method. + # Depends on analyze-types below. + - ^spew\.(ConfigState\.)?Dump$ + # The package name might be ambiguous. + # The full import path can be used as additional criteria. + # Depends on analyze-types below. + - p: ^v1.Dump$ + pkg: ^example.com/pkg/api/v1$ + +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + # - cyclop + - decorder + - depguard + - errcheck + # - errchkjson + - errorlint + - forbidigo + # - forcetypeassert + - funlen + - ineffassign + - gocognit + - gocyclo + - goheader + - gomodguard + - goprintffuncname + - gosimple + - gosec + - grouper + - importas + - maintidx + - misspell + - nakedret + - nilerr + - nilnil + # - noctx + - nosprintfhostport + - paralleltest + - predeclared + # - promlinter + - reassign + - sqlclosecheck + - staticcheck + - tenv + - testpackage + - tparallel + # del + # - typecheck + - usestdlibvars + - nestif + - unused + - makezero + - govet + - goconst + - gci + # - rowserrcheck + # 1.59 version no new lints + # 1.58 version new lints + # - fatcontext + - canonicalheader + # 1.57 version new lints + - copyloopvar + - intrange + # 1.56 version new lints + - spancheck + # 1.55 version new lints + - gochecksumtype + - perfsprint + - sloglint + - testifylint + - mirror + - zerologlint + # 1.51 version new lints + - gocheckcompilerdirectives + # 1.50 version new lints + - testableexamples + +issues: + # Note: path identifiers are regular expressions, hence the \.go suffixes. + exclude-rules: + - path: main\.go + linters: + - forbidigo + - path: _test\.go + linters: + - dogsled + - errcheck + - goconst + - gosec + - ineffassign + - maintidx + - typecheck + - path: \.go$ + text: "should have a package comment" + - path: \.go$ + text: 'exported (.+) should have comment( \(or a comment on this block\))? or be unexported' + - path: \.go$ + text: "fmt.Sprintf can be replaced with string concatenation" + - path: \.go$ + text: "fmt.Errorf can be replaced with errors.New" diff --git a/components/execd/DEVELOPMENT.md b/components/execd/DEVELOPMENT.md new file mode 100644 index 0000000..3c3dd72 --- /dev/null +++ b/components/execd/DEVELOPMENT.md @@ -0,0 +1,121 @@ +# Development Guide - execd + +## Getting Started + +### Prerequisites + +- **Go 1.24+** — match `go.mod` +- **Make** — build automation +- **Docker/Podman** — containerized testing (optional) +- **Jupyter Server** — required for integration tests + +### Setup + +```bash +cd components/execd +go mod download +make build # → bin/execd +``` + +## Project Structure + +``` +execd/ +├── main.go # Entry point +├── Makefile # Build automation +├── Dockerfile # Container image +├── pkg/ +│ ├── flag/ # CLI flag parsing +│ ├── web/ +│ │ ├── router.go # Gin route registration +│ │ ├── controller/ # Request handlers +│ │ └── model/ # API request/response models +│ ├── runtime/ # Execution engine +│ │ ├── ctrl.go # Main controller +│ │ ├── jupyter.go # Jupyter kernel execution +│ │ ├── command.go # Shell command execution +│ │ └── bash_session.go # Pipe-based bash sessions +│ ├── jupyter/ # Jupyter HTTP/WebSocket client +│ ├── telemetry/ # OTLP metrics +│ ├── clone3compat/ # Linux clone3 seccomp workaround +│ └── log/ # Structured logger wrapper +└── tests/ # Integration test scripts +``` + +### Key Patterns + +- **Controller pattern** (`pkg/web/controller`): thin Gin handlers that parse requests, validate, delegate to runtime, and stream responses via SSE. +- **Runtime controller** (`pkg/runtime`): dispatches to Jupyter, Command, or SQL executors; manages session lifecycle. +- **Hook-based streaming**: execution results flow through hooks, decoupling runtime events from SSE serialization. + +## Testing + +### Unit Tests + +```bash +go test ./pkg/... +go test -v -cover ./pkg/... +``` + +### Integration Tests + +Require a running Jupyter server: + +```bash +export JUPYTER_URL=http://localhost:8888 +export JUPYTER_TOKEN=your-token +go test -v ./pkg/jupyter/... +``` + +## Common Tasks + +### Adding a New API Endpoint + +1. Define request/response model in `pkg/web/model/`. + +2. Add controller method in `pkg/web/controller/`: + +```go +func (c *MyController) NewFeature() { + var req model.NewFeatureRequest + if err := c.Ctx.ShouldBindJSON(&req); err != nil { + c.Ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // ... +} +``` + +3. Register route in `pkg/web/router.go`: + +```go +myGroup := r.Group("/my-feature") +{ + myGroup.POST("", withMyController(func(c *controller.MyController) { c.NewFeature() })) +} +``` + +### Adding a Configuration Flag + +1. Declare variable in `pkg/flag/flags.go`. +2. In `pkg/flag/parser.go`, read env var first, then register `flag.*Var` with current value as default — flag overrides env. +3. Update `README.md` CLI Flags and Environment Variables tables. + +### Debugging SSE Streams + +```bash +curl -N -H "Content-Type: application/json" \ + -d '{"language":"python","code":"print(\"test\")"}' \ + http://localhost:44772/code +``` + +`-N` disables buffering for real-time events. + +## Useful Commands + +```bash +make fmt # gofmt +make golint # lint +make test # all tests +make build # binary → bin/execd +``` diff --git a/components/execd/Dockerfile b/components/execd/Dockerfile new file mode 100644 index 0000000..9d10f6d --- /dev/null +++ b/components/execd/Dockerfile @@ -0,0 +1,102 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM golang:1.25.9 AS builder + +WORKDIR /build + +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown +ARG GOFLAGS= +ARG LDFLAGS= +ARG CGO_ENABLED=0 +ARG CC= +ARG CXX= +ARG CFLAGS= +ARG CXXFLAGS= +ARG CGO_CFLAGS= +ARG CGO_CXXFLAGS= +ARG CGO_LDFLAGS= + +# Prepare local modules to satisfy replace directives. +COPY components/internal/go.mod components/internal/go.sum ./components/internal/ +COPY components/execd/go.mod components/execd/go.sum ./components/execd/ + +# Download deps with only mod files for better caching. +RUN cd components/internal && go mod download +RUN cd components/execd && go mod download + +# Copy sources. +COPY components/internal ./components/internal +COPY components/execd ./components/execd + +WORKDIR /build/components/execd + +# Build the opensandbox-supervisor binary from the internal module. +RUN cd /build/components/internal && \ + CGO_ENABLED=0 go build -trimpath -buildvcs=false \ + -ldflags "-buildid= -B none \ + -X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ + -o /build/opensandbox-supervisor ./cmd/supervisor + +RUN if [ -n "${CC}" ]; then export CC; fi; \ + if [ -n "${CXX}" ]; then export CXX; fi; \ + export CGO_ENABLED="${CGO_ENABLED}" \ + CGO_CFLAGS="${CGO_CFLAGS:-${CFLAGS}}" \ + CGO_CXXFLAGS="${CGO_CXXFLAGS:-${CXXFLAGS}}" \ + CGO_LDFLAGS="${CGO_LDFLAGS}"; \ + go build ${GOFLAGS} -trimpath -buildvcs=false \ + -ldflags "${LDFLAGS} -buildid= -B none \ + -X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ + -o /build/execd ./main.go + +RUN CGO_ENABLED=0 GOOS=windows go build ${GOFLAGS} -trimpath -buildvcs=false \ + -ldflags "${LDFLAGS} -buildid= -B none \ + -X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ + -o /build/execd.exe ./main.go + +# Build static bubblewrap with musl. +FROM alpine:latest AS bwrap-builder +RUN apk add --no-cache git musl-dev meson ninja gcc libcap-dev libcap-static pkgconfig bash +RUN git clone --depth 1 --branch v0.11.2 \ + https://github.com/containers/bubblewrap /build/bwrap +WORKDIR /build/bwrap +RUN rm /usr/lib/libcap.so /usr/lib/libcap.so.2 && \ + meson setup builddir \ + -Dprefix=/usr \ + -Dman=disabled \ + -Dtests=false \ + -Dsupport_setuid=false \ + -Ddefault_library=static \ + -Dc_link_args='-static' && \ + ninja -C builddir && \ + cp builddir/bwrap /build/bwrap/bwrap + +FROM alpine:latest + +COPY --from=bwrap-builder /build/bwrap/bwrap /usr/local/bin/bwrap +COPY --from=builder /build/execd . +COPY --from=builder /build/execd.exe ./execd.exe +COPY --from=builder /build/opensandbox-supervisor ./opensandbox-supervisor +COPY components/execd/bootstrap.sh ./bootstrap.sh +COPY components/execd/install.bat ./install.bat + +ENTRYPOINT ["./execd"] diff --git a/components/execd/Dockerfile.dockerignore b/components/execd/Dockerfile.dockerignore new file mode 100644 index 0000000..848e51a --- /dev/null +++ b/components/execd/Dockerfile.dockerignore @@ -0,0 +1,5 @@ +** +!components/execd/** +!components/internal/** +!execd/** +!internal/** diff --git a/components/execd/Makefile b/components/execd/Makefile new file mode 100644 index 0000000..c3f0be0 --- /dev/null +++ b/components/execd/Makefile @@ -0,0 +1,58 @@ +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go mod tidy && go mod vendor + go vet ./... + +.PHONY: test +test: vet ## Run tests + go test -v -coverpkg=./... ./pkg/... + +##@ Linter + +.PHONY: install-golint +install-golint: + @if ! command -v golangci-lint &> /dev/null; then \ + echo "installing golangci-lint..."; \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ + else \ + echo "golangci-lint already installed"; \ + fi + +.PHONY: golint +golint: fmt install-golint + "$$(go env GOPATH)/bin/golangci-lint" run -v ./... + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "dev") +GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") +BUILD_TIME ?= $(shell if [ -n "$$SOURCE_DATE_EPOCH" ]; then date -u -d "@$$SOURCE_DATE_EPOCH" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -r "$$SOURCE_DATE_EPOCH" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null; else date -u +"%Y-%m-%dT%H:%M:%SZ"; fi) +PROJECT_GOFLAGS := -trimpath -buildvcs=false +PROJECT_LDFLAGS := -buildid= -B none -X 'github.com/alibaba/opensandbox/internal/version.Version=$(VERSION)' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=$(BUILD_TIME)' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=$(GIT_COMMIT)' +GO_BUILD_FLAGS := $(strip $(GOFLAGS) $(PROJECT_GOFLAGS)) +GO_LDFLAGS := $(strip $(LDFLAGS) $(PROJECT_LDFLAGS)) + +.PHONY: build +build: vet ## Build the binary. + @mkdir -p bin + go build $(GO_BUILD_FLAGS) -ldflags "$(GO_LDFLAGS)" -o bin/execd main.go + +.PHONY: test-integration +test-integration: ## Run integration tests (Linux + bwrap required). + go test -v -tags="linux,bwrap" -run Integration ./pkg/runtime/bwrap_test/ + +.PHONY: multi-build +multi-build: vet ## Cross-compile for linux/windows/darwin amd64/arm64. + @mkdir -p bin + @for os in linux windows darwin; do \ + for arch in amd64 arm64; do \ + out=bin/execd_$(VERSION)_$${os}_$${arch}; \ + [ "$${os}" = "windows" ] && out="$${out}.exe"; \ + echo ">> building $${os}/$${arch} -> $${out}"; \ + GOOS=$${os} GOARCH=$${arch} CGO_ENABLED=0 go build $(GO_BUILD_FLAGS) -ldflags "$(GO_LDFLAGS)" -o "$${out}" main.go || exit $$?; \ + done; \ + done diff --git a/components/execd/PTY.md b/components/execd/PTY.md new file mode 100644 index 0000000..a1ba291 --- /dev/null +++ b/components/execd/PTY.md @@ -0,0 +1,42 @@ +# Interactive PTY sessions + +Use this when you need a **long-lived Bash** driven over **WebSocket**: PTY mode behaves like a real terminal (colors, `stty`, resize); **pipe mode** (`pty=0`) splits stdout/stderr without a TTY. **Unix/macOS/Linux only** — not supported on Windows. + +## Typical usage + +1. **Create a session** (shell starts on the first WebSocket, not here): + + ```bash + curl -s -X POST http://127.0.0.1:44772/pty \ + -H 'Content-Type: application/json' \ + -d '{"cwd":"/tmp"}' + # → { "session_id": "" } + ``` + +2. **Open WebSocket** — default is PTY mode: + + ``` + ws://127.0.0.1:44772/pty//ws + ``` + + | Query | Use | + |-------|-----| + | `pty=0` | Pipe mode instead of PTY | + | `since=` | After reconnect, replay from byte offset (use `output_offset` from `GET /pty/:id`) | + | `takeover=1` | Evict the current holder instead of getting **409**, then attach to the same shell (combine with `since=` to replay scrollback) | + +3. **Traffic** — after a JSON `connected` frame, the server sends **binary** chunks: first byte is the channel (`0x01` stdout, `0x02` stderr in pipe mode only, `0x03` replay with an 8-byte offset header). Send **stdin** as binary: `0x00` + raw bytes. For **resize** / **signals** / **ping**, send **JSON text** frames, e.g. `{"type":"resize","cols":120,"rows":40}`, `{"type":"signal","signal":"SIGINT"}`, `{"type":"ping"}`. + +4. **One WebSocket per session** — a second connection gets **409** until the first closes, unless it passes **`?takeover=1`**: the current holder is then closed with WebSocket code **4001** (reason `TAKEN_OVER`) and the new connection takes over the **same** shell. This lets a session move between clients/devices without restarting Bash. + +5. **End** — when Bash exits, you get a JSON `exit` frame with `exit_code` and the socket closes. Use **`DELETE /pty/:id`** to tear down the session from the server side. + +## Modes + +- **PTY (default)** — ANSI and TTY-aware tools work as usual. +- **Pipe** — `?pty=0`; stderr is separate binary frames. Good when you do not need a TTY. + +## Notes + +- Output is also buffered for **replay**; reconnect with `since=` to catch up. +- In PTY streams, **shell echo** may appear before your command’s real output, so avoid matching only on text that also appears in the typed line. diff --git a/components/execd/README.md b/components/execd/README.md new file mode 100644 index 0000000..1102e6a --- /dev/null +++ b/components/execd/README.md @@ -0,0 +1,4 @@ +# OpenSandbox execd + +Documentation: [docs/components/execd.md](../../docs/components/execd.md) + diff --git a/components/execd/RELEASE_NOTES.md b/components/execd/RELEASE_NOTES.md new file mode 100644 index 0000000..e732438 --- /dev/null +++ b/components/execd/RELEASE_NOTES.md @@ -0,0 +1,305 @@ +# components/execd 1.0.12 + +## What's New + +### ✨ Features +- trust mitm proxy if `OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT` set (#630) + +### 🐛 Bug Fixes +- normalize traceback for command start errors (#701) +- resolved issue which execd cannot process file like `$HOME/abc`, `~/abc` or `$MY_WORKSPACE/abc` (#726) + +### 📦 Misc +- optimize Makefile for multi-build release (#695) +- add Dockerfile.dockerignore to reduce build context (#718) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @Aboysky + +--- +- Docker Hub: opensandbox/execd:v1.0.12 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.12 + +# components/execd 1.0.11 + +## What's New + +### 🐛 Bug Fixes +- fix deduplicate context entries in `ListAllContexts` and `LanguageSessions` (#619) +- enlarge default jupyter polling interval to 100ms (#650) +- validate request.cwd and return 400 (#656) +- Bind token injection to an allowlisted host/scheme (e.g., compare req.URL.Host to the expected Jupyter endpoint before setting Authorization), and/or disable redirects for this client (CheckRedirect) unless explicitly safe (#680) + +### 📦 Misc +- bump google.golang.org/grpc from 1.79.2 to 1.79.3 in /components/internal (#652) +- extract safego to internal common package and wrapper execd goroutines with safego (#670) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @ZYecho11 +- @Pangjiping +- @dependabot +- @tomaioo + +--- +- Docker Hub: opensandbox/execd:v1.0.11 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.11 + +# components/execd 1.0.10 + +## What's New + +### ✨ Features +- tune jupyter idle polling and sse completion wait (#577) +- add websocket PTY support (#590) (#608) +- add EXECD_CLONE3_COMPAT seccomp-based clone3 fallback (#518) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @skyler0513 +- @ctlaltlaltc +- @Pangjiping + +--- +- Docker Hub: opensandbox/execd:v1.0.10 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.10 + +# components/execd 1.0.9 + +## What's New + +### ⚠️ Breaking Changes +- align the execd runInSession contract from `code` / `timeout_ms` to `command` / `timeout`, and update the execd server handlers accordingly (#548) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @ninan-nn + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.8 + +## What's New + +### ✨ Features +- add Session API for pipe-based bash sessions in execd (#104) + +### 🐛 Bug Fixes +- fix goroutine/fd leaks in runCommand when cmd.Start() fails; fix background command stdin still reading from real stdin instead of /dev/null; exit with non-zero code when execd server fails to start; fix panic on empty SQL query and missing `rows.Err()` check (#468) +- encode non-ASCII filenames in Content-Disposition header (#472) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @wishhyt +- @csdbianhua + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.7 + +## What's New + +### ✨ Features +- add support env in run command request (#385) +- add fallback from bash to sh for Alpine-based images (#407) +- add uid and gid support for command execution (#332) +- extract version package to components/internal (#245) +- replace logger with internal package (#237) + +### 🐛 Bug Fixes +- auto-recreate temp dir in stdLogDescriptor and combinedOutputDescriptor (#415) +- return 404 code for missing code context (#373) + +### 📦 Misc +- refactor unit tests to testify require/assert (#385) +- sync latest image for v-prefixed TAG (#331) +- chore(deps): bump filippo.io/edwards25519 from 1.1.0 to 1.1.1 in /components/execd (#251) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @joaquinescalante23 +- @zerone0x +- @liuxiaopai-ai +- @Jah-yee +- @dependabot + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.6 + +## What's New + +### ✨ Features +- human-readable logs and concise SSE summary log (#219) +- add timeout for run_command request (#218) + +### 📦 Misc +- sync execd's log to hostpath and upload artifact (#222) +- chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.45.0 in /components/execd (#193) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @dependabot + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.5 + +## What's New + +### 🐛 Bug Fixes +- flush trailing stdout line without newline (#148) +- remove `omitempty` under FileInfo model (#150) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.4 + +## What's New + +### ✨ Features +- replace `sh` to `bash` under bootstrap (#134) +- allow configuring log output file via env `EXECD_LOG_FILE` (#135) + +### 🐛 Bug Fixes +- support chained bootstrap commands via `-c` or `BOOTSTRAP_CMD` (#129) +- step sse ping after client disconnect (#130) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @hittyt +- @ninan-nn + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.3 + +## What's New + +### ✨ Features +- modify web framework to Gin (#94) +- support parse SSE api grace shutdown timeout from env `EXECD_API_GRACE_SHUTDOWN` (#101) + +### 📦 Misc +- use local tag execd's image built by source (#107) +- fix compile error caused by code merge (#103) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @hittyt +- @jwx0925 + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.2 + +## What's New + +### ✨ Features +- new APIs for code context management (#48) +- tail background command outputs (#64) +- support EXECD_ENVS env file injection (#70) +- add `set -x` before exec "$@" for debug trace user command (#90) + +### 🐛 Bug Fixes +- fix single-line output truncation bug (#79) + +### 📦 Misc +- compile execd's image from source during e2e workflow (#88) +- add context management integration test (#83) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @hittyt +- @ninan-nn + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.1 + +## What's New + +### ✨ Features +- support CR-delimited output (#25) +- [beta] expose command status/output apis, track exit info, and document RFC3339 timestamps for command responses (#26) +- add windows platform support (#32) +- set up standard bootstrap script in execd image with multi-provision compatibility (#60) + +### 🐛 Bug Fixes +- fix the issue where command init message arrives before memory state, causing interrupt to throw a 404 error (#33) + +### 📦 Misc +- add execd unit-test and smoke test workflow (#2) +- add/optmize OpenSandbox /components image release workflow (#7) +- free GitHub-hosted runner's disk space before docker build, which provides us with greater available disk space (#10) +- optmize smoke test scripts by cross platform (#32) +- add unit test for `run_command` api (#38) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping +- @hittyt +- @hellomypastor +- @jwx0925 + +--- +- Docker Hub: opensandbox/execd:v1.0.9 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 + +# components/execd 1.0.0 + +Notes: + +### 🎉 first release diff --git a/components/execd/bootstrap.sh b/components/execd/bootstrap.sh new file mode 100755 index 0000000..a720a62 --- /dev/null +++ b/components/execd/bootstrap.sh @@ -0,0 +1,336 @@ +#!/bin/sh + +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +_forward_signal() { + sig="$1" + pid="$2" + kill "-$sig" "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + exit 0 +} + +# Returns 0 if the value looks like a boolean "true" (1, true, yes, on). +is_truthy() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + +_sudo() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo -n "$@" + else + "$@" + fi +} + +# Install mitm CA into the system trust store (for non-Python programs) +# and set OPENSANDBOX_MERGED_CA to a PEM bundle containing a full root +# set + mitm CA (for env vars like REQUESTS_CA_BUNDLE that *replace* +# rather than append to the default roots). +OPENSANDBOX_MERGED_CA="" +trust_mitm_ca() { + cert="$1" + merged="/opt/opensandbox/merged-ca-certificates.pem" + + # 1) Try to install into the system trust store (best-effort). + if command -v update-ca-certificates >/dev/null 2>&1; then + _sudo mkdir -p /usr/local/share/ca-certificates \ + && _sudo cp "$cert" /usr/local/share/ca-certificates/opensandbox-mitmproxy-ca.crt \ + && _sudo update-ca-certificates \ + || echo "warning: update-ca-certificates failed; system trust store may not include mitm CA" >&2 + elif command -v update-ca-trust >/dev/null 2>&1; then + _sudo mkdir -p /etc/pki/ca-trust/source/anchors \ + && _sudo cp "$cert" /etc/pki/ca-trust/source/anchors/opensandbox-mitmproxy-ca.pem \ + && { _sudo update-ca-trust extract || _sudo update-ca-trust; } \ + || echo "warning: update-ca-trust failed; system trust store may not include mitm CA" >&2 + else + echo "warning: no system trust-store tooling found (need update-ca-certificates or update-ca-trust)" >&2 + fi + + # 2) Build a merged bundle (complete root set + mitm CA). + # Prefer certifi (full Mozilla root set) over system bundles which + # may be incomplete in minimal Docker images. + certifi_ca="" + if command -v python3 >/dev/null 2>&1; then + certifi_ca="$(python3 -c 'import certifi; print(certifi.where())' 2>/dev/null)" || certifi_ca="" + elif command -v python >/dev/null 2>&1; then + certifi_ca="$(python -c 'import certifi; print(certifi.where())' 2>/dev/null)" || certifi_ca="" + fi + + for candidate in \ + "$certifi_ca" \ + /etc/ssl/certs/ca-certificates.crt \ + /etc/pki/tls/certs/ca-bundle.crt \ + /etc/ssl/cert.pem \ + /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; do + if [ -n "$candidate" ] && [ -f "$candidate" ] && [ -s "$candidate" ]; then + cat "$candidate" "$cert" > "$merged" + OPENSANDBOX_MERGED_CA="$merged" + return 0 + fi + done + + echo "warning: could not locate any CA bundle to merge with mitm CA" >&2 + return 0 +} + +# Chromium/Chrome on Linux do not use only the system trust store: they also honor the per-user +# NSS database at $HOME/.pki/nssdb. Import the same mitm CA there so the browser trusts it. +# Requires certutil (e.g. Alpine: nss-tools, Debian/Ubuntu: libnss3-tools). +trust_mitm_ca_nss() { + cert="$1" + [ -f "$cert" ] || return 0 + [ -n "${HOME:-}" ] && [ -d "$HOME" ] || return 0 + if ! command -v certutil >/dev/null 2>&1; then + return 0 + fi + pki="${HOME}/.pki/nssdb" + if ! mkdir -p "$pki" 2>/dev/null; then + return 0 + fi + if [ -f "$pki/cert9.db" ]; then + nssdb="sql:$pki" + elif [ -f "$pki/cert8.db" ]; then + nssdb="dbm:$pki" + else + nssdb="sql:$pki" + if ! certutil -N -d "$nssdb" --empty-password 2>/dev/null; then + [ -f "$pki/cert9.db" ] || return 0 + fi + fi + nick="opensandbox-mitmproxy" + certutil -D -d "$nssdb" -n "$nick" 2>/dev/null || true + if ! certutil -A -d "$nssdb" -n "$nick" -t "C,," -i "$cert"; then + echo "warning: failed to import mitm CA into NSS at $pki (Chrome may still distrust); need certutil" >&2 + return 0 + fi + return 0 +} + +# Import the mitm CA into every JDK trust store found on the system so that Java +# tooling (Maven, Gradle, HttpClient) trusts the credential-proxy MITM cert. +# Best-effort: missing keytool or import failure only warns, never blocks. +_jdk_import_ca() { + jh="$1" + cert="$2" + kt="$jh/bin/keytool" + [ -x "$kt" ] || return 0 + + alias_name="opensandbox-mitmproxy" + + # Locate the cacerts keystore — JDK 9+ supports -cacerts flag, + # JDK 8 and some vendors require an explicit -keystore path. + ks="" + if [ -f "$jh/lib/security/cacerts" ]; then + ks="$jh/lib/security/cacerts" + elif [ -f "$jh/jre/lib/security/cacerts" ]; then + ks="$jh/jre/lib/security/cacerts" + else + return 0 + fi + + # Remove stale alias first so a regenerated CA cert is always picked up. + if "$kt" -list -alias "$alias_name" -keystore "$ks" -storepass changeit >/dev/null 2>&1; then + _sudo "$kt" -delete -alias "$alias_name" -keystore "$ks" -storepass changeit >/dev/null 2>&1 + fi + + if _sudo "$kt" -importcert -noprompt -trustcacerts \ + -alias "$alias_name" \ + -file "$cert" \ + -keystore "$ks" \ + -storepass changeit >/dev/null 2>&1; then + echo "imported mitm CA into JDK trust store at $ks" + else + echo "warning: failed to import mitm CA into $ks" >&2 + fi +} + +_SEEN_JDKS="" +_try_jdk() { + candidate="$1" + cert="$2" + [ -d "$candidate" ] || return 0 + # Resolve to real path for dedup (POSIX: cd + pwd -P). + real="$(cd "$candidate" 2>/dev/null && pwd -P)" || return 0 + case " $_SEEN_JDKS " in + *" $real "*) return 0 ;; + esac + _SEEN_JDKS="$_SEEN_JDKS $real" + _jdk_import_ca "$real" "$cert" +} + +trust_mitm_ca_jdk() { + cert="$1" + [ -f "$cert" ] || return 0 + _SEEN_JDKS="" + + # 1) $JAVA_HOME if set. + if [ -n "${JAVA_HOME:-}" ]; then + _try_jdk "$JAVA_HOME" "$cert" + fi + + # 2) Scan well-known JDK directories. + for search_dir in /usr/lib/jvm /usr/java /opt/java; do + if [ -d "$search_dir" ]; then + for d in "$search_dir"/*/; do + [ -d "$d" ] && _try_jdk "${d%/}" "$cert" + done + fi + done + # Standalone tarball installs (e.g. /opt/jdk, /opt/jdk-21). + for d in /opt/jdk*; do + [ -d "$d" ] && _try_jdk "$d" "$cert" + done + + # 3) Fallback: resolve `java` on PATH to its JAVA_HOME. + if command -v java >/dev/null 2>&1; then + java_bin="$(command -v java)" + # Follow symlinks (POSIX-portable loop). + while [ -L "$java_bin" ]; do + link_target="$(ls -l "$java_bin" 2>/dev/null | sed 's/.* -> //')" + case "$link_target" in + /*) java_bin="$link_target" ;; + *) java_bin="$(dirname "$java_bin")/$link_target" ;; + esac + done + # java_bin is now e.g. /usr/lib/jvm/java-17/bin/java → JAVA_HOME = grandparent + jh_candidate="$(dirname "$(dirname "$java_bin")")" + _try_jdk "$jh_candidate" "$cert" + fi + + _SEEN_JDKS="" + return 0 +} + +MITM_CA="/opt/opensandbox/mitmproxy-ca-cert.pem" +if is_truthy "${OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT:-}"; then + i=0 + while [ "$i" -lt 300 ]; do + if [ -f "$MITM_CA" ] && [ -s "$MITM_CA" ]; then + break + fi + sleep 1 + i=$((i + 1)) + done + if [ ! -f "$MITM_CA" ] || [ ! -s "$MITM_CA" ]; then + echo "warning: timed out after 300s waiting for $MITM_CA (egress mitm CA export); continuing without system CA trust" >&2 + else + echo "mitm CA ready at $MITM_CA after ${i}s" + if ! trust_mitm_ca "$MITM_CA"; then + echo "warning: failed to install mitm CA into system trust store; TLS interception may not work for system libraries" >&2 + fi + fi + + if [ -f "$MITM_CA" ] && [ -s "$MITM_CA" ]; then + trust_mitm_ca_nss "$MITM_CA" || true + trust_mitm_ca_jdk "$MITM_CA" || true + export NODE_EXTRA_CA_CERTS="$MITM_CA" # additive — Node appends to built-in roots + + # REQUESTS_CA_BUNDLE and SSL_CERT_FILE replace the default bundle, + # so use merged roots (certifi/system CA + mitm CA). + if [ -n "$OPENSANDBOX_MERGED_CA" ] && [ -f "$OPENSANDBOX_MERGED_CA" ]; then + export REQUESTS_CA_BUNDLE="$OPENSANDBOX_MERGED_CA" + export SSL_CERT_FILE="$OPENSANDBOX_MERGED_CA" + else + echo "warning: merged CA bundle not available; REQUESTS_CA_BUNDLE/SSL_CERT_FILE will only contain the mitm CA" >&2 + export REQUESTS_CA_BUNDLE="$MITM_CA" + export SSL_CERT_FILE="$MITM_CA" + fi + fi +fi + +EXECD="${EXECD:=/opt/opensandbox/execd}" + +if [ -z "${EXECD_ENVS:-}" ]; then + EXECD_ENVS="/opt/opensandbox/.env" +fi +if ! mkdir -p "$(dirname "$EXECD_ENVS")" 2>/dev/null; then + echo "warning: failed to create dir for EXECD_ENVS=$EXECD_ENVS" >&2 +fi +if ! touch "$EXECD_ENVS" 2>/dev/null; then + echo "warning: failed to touch EXECD_ENVS=$EXECD_ENVS" >&2 +fi +export EXECD_ENVS + +# Run a user-defined pre-script before launching execd. The script is sourced +# with POSIX `.` (not executed as a child process) so any variables it +# `export`s propagate to execd and the chained command below — a subprocess +# would lose those exports the moment it exits. +if [ -n "${EXECD_BOOTSTRAP_PRE_SCRIPT:-}" ]; then + if [ -f "$EXECD_BOOTSTRAP_PRE_SCRIPT" ] && [ -r "$EXECD_BOOTSTRAP_PRE_SCRIPT" ]; then + # Force `.` to read the literal path; without a slash it would fall + # back to a PATH search and could load the wrong file. + case "$EXECD_BOOTSTRAP_PRE_SCRIPT" in + */*) _pre_script="$EXECD_BOOTSTRAP_PRE_SCRIPT" ;; + *) _pre_script="./$EXECD_BOOTSTRAP_PRE_SCRIPT" ;; + esac + echo "sourcing pre-script $EXECD_BOOTSTRAP_PRE_SCRIPT" + # shellcheck disable=SC1090 + . "$_pre_script" + unset _pre_script + else + echo "warning: EXECD_BOOTSTRAP_PRE_SCRIPT=$EXECD_BOOTSTRAP_PRE_SCRIPT not found or not readable" >&2 + fi +fi + +echo "starting OpenSandbox Execd daemon at $EXECD." +$EXECD & + +# Allow chained shell commands (e.g., /test1.sh && /test2.sh) +# Usage: +# bootstrap.sh -c "/test1.sh && /test2.sh" +# Or set BOOTSTRAP_CMD="/test1.sh && /test2.sh" +CMD="" +if [ "${BOOTSTRAP_CMD:-}" != "" ]; then + CMD="$BOOTSTRAP_CMD" +elif [ $# -ge 1 ] && [ "$1" = "-c" ]; then + shift + CMD="$*" +fi + +SHELL_BIN="${BOOTSTRAP_SHELL:-}" +if [ -z "$SHELL_BIN" ]; then + if command -v bash >/dev/null 2>&1; then + SHELL_BIN="$(command -v bash)" + elif command -v sh >/dev/null 2>&1; then + SHELL_BIN="$(command -v sh)" + else + echo "error: neither bash nor sh found in PATH" >&2 + exit 1 + fi +fi + +if [ "$CMD" != "" ]; then + "$SHELL_BIN" -c "$CMD" & + CMD_PID=$! +elif [ $# -eq 0 ]; then + "$SHELL_BIN" & + CMD_PID=$! +else + "$@" & + CMD_PID=$! +fi + +trap '_forward_signal TERM "$CMD_PID"' TERM + +wait "$CMD_PID" 2>/dev/null +exit $? diff --git a/components/execd/build.sh b/components/execd/build.sh new file mode 100755 index 0000000..b1c9993 --- /dev/null +++ b/components/execd/build.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -ex + +default_build_time() { + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + date -u -d "@${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || + date -u -r "${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" + else + date -u +"%Y-%m-%dT%H:%M:%SZ" + fi +} + +build_arg_if_set() { + local name="$1" + if [[ -n "${!name+x}" ]]; then + BUILD_ARGS+=(--build-arg "${name}=${!name}") + fi +} + +TAG=${TAG:-latest} +GHCR_REPO=${GHCR_REPO:-} +VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")} +GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")} +BUILD_TIME=${BUILD_TIME:-$(default_build_time)} +BUILD_METADATA_FILE=${BUILD_METADATA_FILE:-build/execd-image-metadata.json} +BUILD_ARGS=() +for name in GOFLAGS LDFLAGS CGO_ENABLED CC CXX CFLAGS CXXFLAGS CGO_CFLAGS CGO_CXXFLAGS CGO_LDFLAGS; do + build_arg_if_set "${name}" +done + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..") +cd "${REPO_ROOT}" +mkdir -p "$(dirname "${BUILD_METADATA_FILE}")" + +docker buildx rm execd-builder || true + +docker buildx create --use --name execd-builder + +docker buildx inspect --bootstrap + +docker buildx ls + +IMAGE_TAGS=(-t opensandbox/execd:${TAG} -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:${TAG}) +LATEST_TAGS=() +if [[ -n "${GHCR_REPO}" ]]; then + IMAGE_TAGS+=(-t "${GHCR_REPO}/execd:${TAG}") +fi +if [[ "${TAG}" == v* ]]; then + LATEST_TAGS+=(-t opensandbox/execd:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest) + if [[ -n "${GHCR_REPO}" ]]; then + LATEST_TAGS+=(-t "${GHCR_REPO}/execd:latest") + fi +fi + +docker buildx build \ + "${IMAGE_TAGS[@]}" \ + "${LATEST_TAGS[@]}" \ + -f components/execd/Dockerfile \ + "${BUILD_ARGS[@]}" \ + --build-arg VERSION="${VERSION}" \ + --build-arg GIT_COMMIT="${GIT_COMMIT}" \ + --build-arg BUILD_TIME="${BUILD_TIME}" \ + --platform linux/amd64,linux/arm64 \ + --metadata-file "${BUILD_METADATA_FILE}" \ + --push \ + . diff --git a/components/execd/configs/isolation.example.toml b/components/execd/configs/isolation.example.toml new file mode 100644 index 0000000..60f82c9 --- /dev/null +++ b/components/execd/configs/isolation.example.toml @@ -0,0 +1,70 @@ +# Isolation configuration for execd. +# +# Usage: +# execd --isolation-config /etc/execd/isolation.toml +# EXECD_ISOLATION_CONFIG=/etc/execd/isolation.toml execd +# +# All fields are optional. Missing fields use the defaults shown below. +# If no config file is specified, all built-in defaults are used. + +# Parent directory for per-session overlay upper directories. +upper_root = "/var/lib/execd/isolation" + +# Hard limit on total upper directory size across all sessions (bytes). +upper_max_bytes = 8589934592 # 8 GiB + +# Maximum tar.gz diff output size (bytes). Phase 2. +diff_max_bytes = 4294967296 # 4 GiB + +# Host paths callers may request via extra_writable or binds. Sources are +# enforced against the fully symlink-resolved real path; subpaths are allowed. +# NOTE: this key REPLACES the built-in default (no merge), so setting it here +# overrides the defaults below. Empty ([]) rejects all extra_writable/binds. +# Built-in default: ["/workspace", "/mnt", "/media", "/data"]. +allowed_writable = ["/workspace", "/mnt", "/media", "/data"] + +# Seccomp is ALWAYS ACTIVE by default — the built-in denylist blocks ~30 +# dangerous syscalls (mount, ptrace, bpf, etc.) even without this section. +# +# Only add [seccomp] if you need to REPLACE the built-in denylist with a +# custom one. When present, deny COMPLETELY REPLACES the built-in list — +# no merging. Syscalls not present on the current architecture are silently +# skipped. +# +# Built-in default denylist for reference: +# +# [seccomp] +# deny = [ +# # Filesystem manipulation +# "mount", "umount2", "chroot", "pivot_root", +# +# # Process introspection / manipulation +# "ptrace", "process_vm_readv", "process_vm_writev", "kcmp", +# +# # Kernel module loading +# "init_module", "finit_module", "delete_module", +# +# # BPF / seccomp manipulation +# "bpf", "seccomp", +# +# # Execution domain +# "personality", +# +# # Kernel key management +# "add_key", "request_key", "keyctl", +# +# # I/O privilege +# "iopl", "ioperm", +# +# # System state +# "reboot", "syslog", "swapon", "swapoff", +# +# # Namespace manipulation +# "setns", "unshare", +# +# # Handle-based operations +# "name_to_handle_at", "open_by_handle_at", +# +# # Other potentially dangerous +# "userfaultfd", "kexec_load", "kexec_file_load", "acct", +# ] diff --git a/components/execd/docs/opentelemetry.md b/components/execd/docs/opentelemetry.md new file mode 100644 index 0000000..8b16a26 --- /dev/null +++ b/components/execd/docs/opentelemetry.md @@ -0,0 +1,48 @@ +# OpenTelemetry Metrics (Current Execd Support) + +This page lists the OpenTelemetry metrics currently implemented in execd. + +## Meter + +- `opensandbox/execd` + +## Metrics + +| Metric | Type | Unit | Attributes | Meaning | +|---|---|---|---|---| +| `execd.http.request.duration` | Histogram | `ms` | `http_method`, `http_route`, `http_status_code` | HTTP request latency. | +| `execd.execution.duration` | Histogram | `ms` | `operation`, `result` | Code/command execution duration. | +| `execd.filesystem.operations.duration` | Histogram | `ms` | `operation`, `result` | Filesystem operation duration. | +| `execd.system.process.count` | Observable Gauge | - | - | Current process count. | +| `execd.system.cpu.usage` | Observable Gauge | `%` | - | System CPU usage percent (gopsutil). | +| `execd.system.memory.usage_bytes` | Observable Gauge | `By` | - | System memory used bytes (gopsutil). | +| `execd.system.network.io.bytes` | Observable Counter | `By` | `direction` (`in`/`out`) | Cumulative network I/O bytes. | +| `execd.system.network.connections.active` | Observable Gauge | - | `protocol` (`tcp`/`udp`) | Current active network connections. | + +## Shared Attributes + +All execd metrics may include shared attributes: + +- `sandbox_id` from `OPENSANDBOX_ID` (when set) +- extra key/value attributes from `OPENSANDBOX_EXECD_METRICS_EXTRA_ATTRS` (when set) + +## OTEL Endpoint Configuration + +Metric export is enabled only when at least one OTLP endpoint is set. + +- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (preferred) +- `OTEL_EXPORTER_OTLP_ENDPOINT` (fallback) + +If both are unset, execd keeps metrics local (no OTLP export). + +### Minimal Example + +```bash +export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://otel-collector:4318" +``` + +### Optional Service Name + +```bash +export OTEL_SERVICE_NAME="opensandbox-execd" +``` diff --git a/components/execd/go.mod b/components/execd/go.mod new file mode 100644 index 0000000..5af07f0 --- /dev/null +++ b/components/execd/go.mod @@ -0,0 +1,86 @@ +module github.com/alibaba/opensandbox/execd + +go 1.25.0 + +require ( + github.com/alibaba/opensandbox/internal v0.0.0-00010101000000-000000000000 + github.com/bmatcuk/doublestar/v4 v4.9.1 + github.com/creack/pty v1.1.24 + github.com/elastic/go-seccomp-bpf v1.6.0 + github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/validator/v10 v10.28.0 + github.com/go-sql-driver/mysql v1.8.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/shirou/gopsutil v3.21.11+incompatible + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.uber.org/automaxprocs v1.6.0 + golang.org/x/sys v0.42.0 + k8s.io/apimachinery v0.34.2 + k8s.io/client-go v0.34.2 +) + +require ( + filippo.io/edwards25519 v1.1.1 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect +) + +replace github.com/alibaba/opensandbox/internal => ../internal diff --git a/components/execd/go.sum b/components/execd/go.sum new file mode 100644 index 0000000..526dfcb --- /dev/null +++ b/components/execd/go.sum @@ -0,0 +1,227 @@ +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elastic/go-seccomp-bpf v1.6.0 h1:NYduiYxRJ0ZkIyQVwlSskcqPPSg6ynu5pK0/d7SQATs= +github.com/elastic/go-seccomp-bpf v1.6.0/go.mod h1:5tFsTvH4NtWGfpjsOQD53H8HdVQ+zSZFRUDSGevC0Kc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/components/execd/install.bat b/components/execd/install.bat new file mode 100644 index 0000000..db1ff17 --- /dev/null +++ b/components/execd/install.bat @@ -0,0 +1,91 @@ +REM Copyright 2026 Alibaba Group Holding Ltd. +REM +REM Licensed under the Apache License, Version 2.0 (the "License"); +REM you may not use this file except in compliance with the License. +REM You may obtain a copy of the License at +REM +REM http://www.apache.org/licenses/LICENSE-2.0 +REM +REM Unless required by applicable law or agreed to in writing, software +REM distributed under the License is distributed on an "AS IS" BASIS, +REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +REM See the License for the specific language governing permissions and +REM limitations under the License. + +@echo off +setlocal enableextensions + +set "EXECD_LOG_FILE=%EXECD_LOG_FILE%" +if "%EXECD_LOG_FILE%"=="" set "EXECD_LOG_FILE=C:\OpenSandbox\install.log" + +set "EXECD_STDOUT_LOG=%EXECD_STDOUT_LOG%" +if "%EXECD_STDOUT_LOG%"=="" set "EXECD_STDOUT_LOG=C:\OpenSandbox\execd.stdout.log" + +set "EXECD_STDERR_LOG=%EXECD_STDERR_LOG%" +if "%EXECD_STDERR_LOG%"=="" set "EXECD_STDERR_LOG=C:\OpenSandbox\execd.stderr.log" + +set "EXECD_OEM_BIN=C:\OEM\execd.exe" + +if not exist "C:\OpenSandbox" mkdir "C:\OpenSandbox" +if errorlevel 1 ( + echo [install.bat] WARN: failed to create log dir: C:\OpenSandbox + exit /b 0 +) + +call :log "startup begin" +call :log "oem bin: %EXECD_OEM_BIN%" +call :log "execd stdout log: %EXECD_STDOUT_LOG%" +call :log "execd stderr log: %EXECD_STDERR_LOG%" + +if not exist "%EXECD_OEM_BIN%" ( + call :log "WARN: execd binary not found at %EXECD_OEM_BIN%" + exit /b 0 +) + +call :prepare_executable "%EXECD_OEM_BIN%" +call :ensure_firewall_rule + +call :log "starting %EXECD_OEM_BIN%" +set "EXECD_BIN_PS=%EXECD_OEM_BIN%" +set "EXECD_STDOUT_LOG_PS=%EXECD_STDOUT_LOG%" +set "EXECD_STDERR_LOG_PS=%EXECD_STDERR_LOG%" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:EXECD_BIN_PS; $out=$env:EXECD_STDOUT_LOG_PS; $err=$env:EXECD_STDERR_LOG_PS; try { Start-Process -FilePath $p -WindowStyle Hidden -RedirectStandardOutput $out -RedirectStandardError $err; exit 0 } catch { exit 1 }" >nul 2>&1 +if errorlevel 1 ( + call :log "WARN: PowerShell Start-Process failed, fallback to cmd start" + start "opensandbox-execd" /B cmd /c ""%EXECD_OEM_BIN%" 1>>"%EXECD_STDOUT_LOG%" 2>>"%EXECD_STDERR_LOG%"" +) +if errorlevel 1 ( + call :log "WARN: failed to start execd.exe via both powershell and cmd" + exit /b 0 +) + +call :log "execd started in background" +exit /b 0 + +:log +echo [install.bat] %~1 +>>"%EXECD_LOG_FILE%" echo [%date% %time%] [install.bat] %~1 +exit /b 0 + +:prepare_executable +set "TARGET_BIN=%~1" +if "%TARGET_BIN%"=="" exit /b 0 +set "TARGET_BIN_PS=%TARGET_BIN%" +call :log "preparing executable security metadata for %TARGET_BIN%" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$p=$env:TARGET_BIN_PS; try { if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath ($p + ':Zone.Identifier') -ErrorAction SilentlyContinue; Unblock-File -LiteralPath $p -ErrorAction SilentlyContinue }; exit 0 } catch { exit 0 }" >nul 2>&1 +if errorlevel 1 ( + call :log "WARN: executable prepare returned non-zero" +) +exit /b 0 + +:ensure_firewall_rule +set "EXECD_FW_RULE=OpenSandbox execd 44772" +call :log "ensuring firewall rule for TCP 44772" +netsh advfirewall firewall delete rule name="%EXECD_FW_RULE%" >nul 2>&1 +netsh advfirewall firewall add rule name="%EXECD_FW_RULE%" dir=in action=allow protocol=TCP localport=44772 >nul 2>&1 +if errorlevel 1 ( + call :log "WARN: failed to add firewall allow rule for TCP 44772" + exit /b 0 +) +call :log "firewall rule ready for TCP 44772" +exit /b 0 diff --git a/components/execd/main.go b/components/execd/main.go new file mode 100644 index 0000000..44920aa --- /dev/null +++ b/components/execd/main.go @@ -0,0 +1,107 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "net" + "os" + "time" + + "github.com/alibaba/opensandbox/internal/version" + + _ "github.com/alibaba/opensandbox/internal/safego" + _ "go.uber.org/automaxprocs/maxprocs" + + "github.com/alibaba/opensandbox/execd/pkg/clone3compat" + "github.com/alibaba/opensandbox/execd/pkg/flag" + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/telemetry" + "github.com/alibaba/opensandbox/execd/pkg/web" + "github.com/alibaba/opensandbox/execd/pkg/web/controller" +) + +func main() { + clone3Compat := clone3compat.MaybeApply() + + version.EchoVersion("OpenSandbox Execd") + + flag.InitFlags() + + // Load isolation config. + isoCfg, err := isolation.LoadConfig(flag.IsolationConfigPath) + if err != nil { + log.Error("isolation: config: %v", err) + os.Exit(1) + } + + // Probe isolation runtime capabilities. + isolationProbe := isolation.Probe(isolation.ProbeConfig{ + UpperRoot: isoCfg.UpperRoot, + UpperMaxBytes: isoCfg.UpperMaxBytes, + }) + log.Info("isolation: available=%v isolator=%s version=%s", + isolationProbe.Available, isolationProbe.Isolator, isolationProbe.Version) + + log.Init(flag.ServerLogLevel) + + ctrl := controller.InitCodeRunner() + + // Always store probe result for capabilities endpoint. + controller.InitIsolatedProbe(&isolationProbe) + + // Init isolation runner if probe succeeded. + if isolationProbe.Available { + iso := isolation.NewBwrap(isoCfg) + runner, err := runtime.NewIsolatedRunner(ctrl, iso, isoCfg) + if err != nil { + log.Error("isolation: runner init failed (continuing without isolation): %v", err) + } else { + controller.InitIsolatedRunner(runner) + log.Info("isolation: runner ready, upper_root=%s", isoCfg.UpperRoot) + } + } + if clone3Compat { + log.Warn("execd running with clone3 compatibility (seccomp returns ENOSYS for clone3)") + } + otelShutdown, err := telemetry.Init(context.Background()) + if err != nil { + log.Warn("OpenTelemetry metrics disabled (continuing without OTLP): %v", err) + otelShutdown = nil + } + if otelShutdown != nil { + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = otelShutdown(shutdownCtx) + }() + } + + engine := web.NewRouter(flag.ServerAccessToken) + addr := fmt.Sprintf(":%d", flag.ServerPort) + listener, err := net.Listen("tcp4", addr) + if err != nil { + log.Error("failed to listen on %s: %v", addr, err) + os.Exit(1) + } + log.Info("execd listening on %s (IPv4)", addr) + if err := engine.RunListener(listener); err != nil { + log.Error("failed to start execd server: %v", err) + os.Exit(1) + } +} diff --git a/components/execd/pkg/clone3compat/compat.go b/components/execd/pkg/clone3compat/compat.go new file mode 100644 index 0000000..e46e783 --- /dev/null +++ b/components/execd/pkg/clone3compat/compat.go @@ -0,0 +1,27 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package clone3compat installs an optional seccomp rule so clone3(2) returns ENOSYS and +// libc / the Go runtime fall back to clone(2), following the same idea as +// https://github.com/AkihiroSuda/clone3-workaround . +// +// Enable on Linux via environment when starting execd: +// +// EXECD_CLONE3_COMPAT=1 — install the filter at process start (after Go runtime init). +// EXECD_CLONE3_COMPAT=reexec — install the filter then re-exec the same binary so all +// package init code runs with the filter already active +// (closest to wrapping with the external clone3-workaround binary). +// +// Disabled when unset or empty, or set to 0, false, off, no. +package clone3compat diff --git a/components/execd/pkg/clone3compat/compat_linux.go b/components/execd/pkg/clone3compat/compat_linux.go new file mode 100644 index 0000000..6a4090c --- /dev/null +++ b/components/execd/pkg/clone3compat/compat_linux.go @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package clone3compat + +import ( + "errors" + "fmt" + "os" + "strings" + "syscall" + + seccomp "github.com/elastic/go-seccomp-bpf" + "golang.org/x/sys/unix" +) + +const ( + envCompat = "EXECD_CLONE3_COMPAT" + envApplied = "_EXECD_CLONE3_COMPAT_APPLIED" +) + +// MaybeApply optionally installs a seccomp rule so clone3 returns ENOSYS, matching the +// behavior of https://github.com/AkihiroSuda/clone3-workaround . +// It returns true if this process is running with that compatibility active (including +// a post-reexec process that inherited the seccomp filter). +func MaybeApply() bool { + mode := strings.ToLower(strings.TrimSpace(os.Getenv(envCompat))) + switch mode { + case "", "0", "false", "off", "no": + return false + case "1", "true", "yes", "on": + if err := loadClone3EnosysFilter(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "execd: %v\n", err) + os.Exit(1) + } + return true + case "reexec": + if os.Getenv(envApplied) == "1" { + return true + } + if err := loadClone3EnosysFilter(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "execd: %v\n", err) + os.Exit(1) + } + if err := os.Setenv(envApplied, "1"); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "execd: clone3 compat: set %s: %v\n", envApplied, err) + os.Exit(1) + } + exe, err := os.Readlink("/proc/self/exe") + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "execd: clone3 compat: readlink /proc/self/exe: %v\n", err) + os.Exit(1) + } + exe = strings.TrimSuffix(exe, " (deleted)") + if err := unix.Exec(exe, os.Args, os.Environ()); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "execd: clone3 compat: exec: %v\n", err) + os.Exit(1) + } + panic("unreachable") // Exec replaces this process. + default: + _, _ = fmt.Fprintf(os.Stderr, "execd: invalid %s=%q (use 1, true, or reexec)\n", envCompat, os.Getenv(envCompat)) + os.Exit(1) + } + + return false +} + +func loadClone3EnosysFilter() error { + if !seccomp.Supported() { + return errors.New("clone3 compat: seccomp is not available on this kernel") + } + f := seccomp.Filter{ + NoNewPrivs: true, + Flag: seccomp.FilterFlagTSync, + Policy: seccomp.Policy{ + DefaultAction: seccomp.ActionAllow, + Syscalls: []seccomp.SyscallGroup{ + { + Names: []string{"clone3"}, + // Not plain ActionErrno: assembler defaults errno to EPERM. + Action: seccomp.ActionErrno | seccomp.Action(syscall.ENOSYS), + }, + }, + }, + } + if err := seccomp.LoadFilter(f); err != nil { + return fmt.Errorf("clone3 compat: %w", err) + } + return nil +} diff --git a/components/execd/pkg/clone3compat/compat_stub.go b/components/execd/pkg/clone3compat/compat_stub.go new file mode 100644 index 0000000..3d82ef1 --- /dev/null +++ b/components/execd/pkg/clone3compat/compat_stub.go @@ -0,0 +1,20 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package clone3compat + +// MaybeApply is a no-op on non-Linux platforms. +func MaybeApply() bool { return false } diff --git a/components/execd/pkg/flag/flags.go b/components/execd/pkg/flag/flags.go new file mode 100644 index 0000000..b55f0d5 --- /dev/null +++ b/components/execd/pkg/flag/flags.go @@ -0,0 +1,45 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flag + +import "time" + +var ( + // JupyterServerHost points to the target Jupyter instance. + JupyterServerHost string + + // JupyterServerToken authenticates requests to the Jupyter server. + JupyterServerToken string + + // ServerPort controls the HTTP listener port. + ServerPort int + + // ServerLogLevel controls the server log verbosity. + ServerLogLevel int + + // ServerAccessToken guards API entrypoints when set. + ServerAccessToken string + + // ApiGracefulShutdownTimeout waits before tearing down SSE streams. + ApiGracefulShutdownTimeout time.Duration + + // JupyterIdlePollInterval controls how often ExecuteCodeStream checks for + // late execute_result/error messages after receiving idle status. + JupyterIdlePollInterval time.Duration + + // IsolationConfigPath points to the TOML isolation config file. + // Empty means use built-in defaults. + IsolationConfigPath string +) diff --git a/components/execd/pkg/flag/parser.go b/components/execd/pkg/flag/parser.go new file mode 100644 index 0000000..f92f33a --- /dev/null +++ b/components/execd/pkg/flag/parser.go @@ -0,0 +1,108 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flag + +import ( + "flag" + stdlog "log" + "os" + "strings" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +const ( + jupyterHostEnv = "JUPYTER_HOST" + jupyterTokenEnv = "JUPYTER_TOKEN" + accessTokenEnv = "EXECD_ACCESS_TOKEN" + gracefulShutdownTimeoutEnv = "EXECD_API_GRACE_SHUTDOWN" + jupyterIdlePollIntervalEnv = "EXECD_JUPYTER_IDLE_POLL_INTERVAL" + isolationConfigEnv = "EXECD_ISOLATION_CONFIG" +) + +// InitFlags registers CLI flags and env overrides. +func InitFlags() { + // Set default values + ServerPort = 44772 + ServerLogLevel = 6 + ServerAccessToken = "" + ApiGracefulShutdownTimeout = time.Second * 1 + JupyterIdlePollInterval = 100 * time.Millisecond + IsolationConfigPath = "" + + // First, set default values from environment variables + if jupyterFromEnv := os.Getenv(jupyterHostEnv); jupyterFromEnv != "" { + if !strings.HasPrefix(jupyterFromEnv, "http://") && !strings.HasPrefix(jupyterFromEnv, "https://") { + stdlog.Panic("Invalid JUPYTER_HOST format: must start with http:// or https://") + } + JupyterServerHost = jupyterFromEnv + } + + if jupyterTokenFromEnv := os.Getenv(jupyterTokenEnv); jupyterTokenFromEnv != "" { + JupyterServerToken = jupyterTokenFromEnv + } + + if accessTokenFromEnv := os.Getenv(accessTokenEnv); accessTokenFromEnv != "" { + ServerAccessToken = accessTokenFromEnv + } + + // Then define flags with current values as defaults + flag.StringVar(&JupyterServerHost, "jupyter-host", JupyterServerHost, "Jupyter server host address (e.g., http://localhost, http://192.168.1.100)") + flag.StringVar(&JupyterServerToken, "jupyter-token", JupyterServerToken, "Jupyter server authentication token") + flag.IntVar(&ServerPort, "port", ServerPort, "Server listening port (default: 44772)") + flag.IntVar(&ServerLogLevel, "log-level", ServerLogLevel, "Server log level (0=LevelEmergency, 1=LevelAlert, 2=LevelCritical, 3=LevelError, 4=LevelWarning, 5=LevelNotice, 6=LevelInformational, 7=LevelDebug, default: 6)") + flag.StringVar(&ServerAccessToken, "access-token", ServerAccessToken, "Server access token for API authentication") + + if graceShutdownTimeout := os.Getenv(gracefulShutdownTimeoutEnv); graceShutdownTimeout != "" { + duration, err := time.ParseDuration(graceShutdownTimeout) + if err != nil { + stdlog.Panicf("Failed to parse graceful shutdown timeout from env: %v", err) + } + ApiGracefulShutdownTimeout = duration + } + + if idlePollInterval := os.Getenv(jupyterIdlePollIntervalEnv); idlePollInterval != "" { + duration, err := time.ParseDuration(idlePollInterval) + if err != nil { + stdlog.Panicf("Failed to parse jupyter idle poll interval from env: %v", err) + } + if duration <= 0 { + stdlog.Printf("Invalid %s=%s; fallback to default %s", jupyterIdlePollIntervalEnv, idlePollInterval, JupyterIdlePollInterval) + } else { + JupyterIdlePollInterval = duration + } + } + + flag.DurationVar(&ApiGracefulShutdownTimeout, "graceful-shutdown-timeout", ApiGracefulShutdownTimeout, "API graceful shutdown timeout duration (default: 1s)") + flag.DurationVar(&JupyterIdlePollInterval, "jupyter-idle-poll-interval", JupyterIdlePollInterval, "Polling interval after Jupyter idle status before closing stream (default: 100ms)") + + // Isolation config + if v := os.Getenv(isolationConfigEnv); v != "" { + IsolationConfigPath = v + } + flag.StringVar(&IsolationConfigPath, "isolation-config", IsolationConfigPath, "Path to isolation TOML config file (default: built-in defaults)") + + // Parse flags - these will override environment variables if provided + flag.Parse() + if JupyterIdlePollInterval <= 0 { + stdlog.Printf("Invalid --jupyter-idle-poll-interval=%s; fallback to default %s", JupyterIdlePollInterval, 100*time.Millisecond) + JupyterIdlePollInterval = 100 * time.Millisecond + } + + // Log final values + log.Info("Jupyter server host is: %s", JupyterServerHost) + log.Info("Jupyter server token is: %s", log.MaskToken(JupyterServerToken)) +} diff --git a/components/execd/pkg/flag/parser_test.go b/components/execd/pkg/flag/parser_test.go new file mode 100644 index 0000000..f2389ca --- /dev/null +++ b/components/execd/pkg/flag/parser_test.go @@ -0,0 +1,73 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flag + +import ( + "flag" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestInitFlagsSanitizesNonPositiveJupyterIdlePollIntervalFromCLI(t *testing.T) { + previousArgs := os.Args + previousCommandLine := flag.CommandLine + defaultPollInterval := 100 * time.Millisecond + + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + os.Args = []string{previousArgs[0], "--jupyter-idle-poll-interval=0"} + t.Cleanup(func() { + os.Args = previousArgs + flag.CommandLine = previousCommandLine + }) + + InitFlags() + + require.Equal(t, defaultPollInterval, JupyterIdlePollInterval) +} + +func TestInitFlagsServerAccessTokenFromEnv(t *testing.T) { + previousArgs := os.Args + previousCommandLine := flag.CommandLine + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + os.Args = []string{previousArgs[0]} + t.Cleanup(func() { + os.Args = previousArgs + flag.CommandLine = previousCommandLine + }) + t.Setenv("EXECD_ACCESS_TOKEN", "test-token-from-env") + + InitFlags() + + require.Equal(t, "test-token-from-env", ServerAccessToken) +} + +func TestInitFlagsCliOverridesEnvAccessToken(t *testing.T) { + previousArgs := os.Args + previousCommandLine := flag.CommandLine + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + os.Args = []string{previousArgs[0], "--access-token=cli-token"} + t.Cleanup(func() { + os.Args = previousArgs + flag.CommandLine = previousCommandLine + }) + t.Setenv("EXECD_ACCESS_TOKEN", "env-token") + + InitFlags() + + require.Equal(t, "cli-token", ServerAccessToken) +} diff --git a/components/execd/pkg/isolation/bwrap.go b/components/execd/pkg/isolation/bwrap.go new file mode 100644 index 0000000..549d808 --- /dev/null +++ b/components/execd/pkg/isolation/bwrap.go @@ -0,0 +1,309 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package isolation + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// buildArgv constructs the bwrap command line from wrap options. +func buildArgv(opts WrapOptions, seccompFd string) ([]string, error) { + if err := validateWrapOptions(opts); err != nil { + return nil, err + } + + useUserns := opts.UidMode == UidModeUserns + + var argv []string + + // 1. Namespace flags. + if useUserns { + argv = append(argv, "--unshare-user") + // --disable-userns is unsupported by the setuid build of bwrap; + // only add it for the non-setuid binary. + if !bwrapIsSetuid { + argv = append(argv, "--disable-userns") + } + } + argv = append(argv, "--unshare-pid", "--unshare-uts", "--hostname", "sandbox", "--unshare-ipc", "--unshare-cgroup") + if !opts.ShareNet { + argv = append(argv, "--unshare-net") + } + if useUserns { + uid := uint32(os.Getuid()) + gid := uint32(os.Getgid()) + if opts.Uid != nil { + uid = *opts.Uid + } + if opts.Gid != nil { + gid = *opts.Gid + } + argv = append(argv, + "--uid", strconv.FormatUint(uint64(uid), 10), + "--gid", strconv.FormatUint(uint64(gid), 10), + ) + } + + // 2. Root filesystem (read-only). + argv = append(argv, "--ro-bind", "/", "/") + + // 3. /tmp — skip if workspace is /tmp (workspace bind would override). + if filepath.Clean(opts.Workspace.Path) != "/tmp" { + argv = append(argv, bwrapTmpSegment(opts.Profile)...) + } + + // 4–6. Virtual filesystems. + argv = append(argv, "--tmpfs", "/run", "--dev", "/dev", "--proc", "/proc") + + // 7. Workspace. + wsArgv, err := bwrapWorkspaceSegment(opts) + if err != nil { + return nil, err + } + argv = append(argv, wsArgv...) + + // Hide upper root to prevent cross-session access. + if opts.UpperDir != "" { + upperRoot := filepath.Dir(filepath.Dir(opts.UpperDir)) + argv = append(argv, "--tmpfs", upperRoot) + } + + // 8. Extra writable paths. + for _, p := range opts.ExtraWritable { + argv = append(argv, "--bind", p, p) + } + + // 8b. Explicit source→dest bind mounts. + for _, b := range opts.Binds { + dest := b.Dest + if dest == "" { + dest = b.Source + } + flag := "--bind" + if b.ReadOnly { + flag = "--ro-bind" + } + argv = append(argv, flag, b.Source, dest) + } + + // 9. Environment. + argv = append(argv, bwrapEnvSegment(opts.EnvPassthrough)...) + + // 10. Seccomp. + if seccompFd != "" { + argv = append(argv, "--seccomp", seccompFd) + } + + // 11. Lifecycle: kill sandbox when execd dies. + // Note: --new-session is intentionally omitted. bwrap is launched with + // SysProcAttr{Setpgid: true}, making it a process-group leader, and + // setsid(2) returns EPERM for a group leader — it would fail every + // session start. Process-group isolation from Setpgid is sufficient. + argv = append(argv, "--die-with-parent") + + // 12. Separator + identity switch. + argv = append(argv, "--") + + // In userns mode, uid/gid are set via --uid/--gid in segment 1. + if !useUserns { + uid := uint32(os.Getuid()) + gid := uint32(os.Getgid()) + if opts.Uid != nil { + uid = *opts.Uid + } + if opts.Gid != nil { + gid = *opts.Gid + } + + if uid != 0 || gid != 0 { + setprivArgv := []string{ + "setpriv", + fmt.Sprintf("--reuid=%d", uid), + fmt.Sprintf("--regid=%d", gid), + "--clear-groups", + } + argv = append(argv, setprivArgv...) + } + } + + return argv, nil +} + +// validateWrapOptions checks for invalid or conflicting options. +func validateWrapOptions(opts WrapOptions) error { + if opts.Workspace.Path == "" { + return errors.New("isolation: workspace.path is required") + } + if !opts.Profile.Valid() { + return fmt.Errorf("isolation: unknown profile %q", opts.Profile) + } + if !opts.Workspace.Mode.Valid() { + return fmt.Errorf("isolation: unknown workspace mode %q", opts.Workspace.Mode) + } + if !opts.EnvPassthrough.Mode.Valid() && opts.EnvPassthrough.Mode != "" { + return fmt.Errorf("isolation: unknown env mode %q", opts.EnvPassthrough.Mode) + } + if opts.UidMode != "" && !opts.UidMode.Valid() { + return fmt.Errorf("isolation: unknown uid mode %q", opts.UidMode) + } + for _, b := range opts.Binds { + if b.Source == "" { + return errors.New("isolation: bind.source is required") + } + if !filepath.IsAbs(b.Source) { + return fmt.Errorf("isolation: bind.source %q must be an absolute path", b.Source) + } + if b.Dest != "" && !filepath.IsAbs(b.Dest) { + return fmt.Errorf("isolation: bind.dest %q must be an absolute path", b.Dest) + } + } + return nil +} + +// bwrapTmpSegment returns the /tmp mount args for the given profile. +func bwrapTmpSegment(p Profile) []string { + switch p { + case ProfileStrict: + return []string{"--tmpfs", "/tmp"} + default: + // balanced and others: share container /tmp. + return []string{"--bind", "/tmp", "/tmp"} + } +} + +// bwrapWorkspaceSegment returns mount args for the workspace. +func bwrapWorkspaceSegment(opts WrapOptions) ([]string, error) { + ws := opts.Workspace + + switch ws.Mode { + case WorkspaceRW: + return []string{"--bind", ws.Path, ws.Path}, nil + + case WorkspaceRO: + return []string{"--ro-bind", ws.Path, ws.Path}, nil + + case WorkspaceOverlay: + if opts.UpperDir == "" { + // tmpfs upper — ephemeral. --tmp-overlay DEST (bwrap v0.11.x). + return []string{"--overlay-src", ws.Path, "--tmp-overlay", ws.Path}, nil + } + workDir := opts.WorkDir + if workDir == "" { + workDir = opts.UpperDir + "-work" + } + // --overlay-src LOWER --overlay RWSRC WORKDIR DEST + return []string{"--overlay-src", ws.Path, "--overlay", opts.UpperDir, workDir, ws.Path}, nil + + default: + return nil, fmt.Errorf("isolation: unknown workspace mode %q", ws.Mode) + } +} + +// unsetBlacklistedEnv returns --unsetenv args for all env vars matching strictEnvBlacklist. +func unsetBlacklistedEnv() []string { + var argv []string + for _, pattern := range strictEnvBlacklist { + for _, env := range os.Environ() { + kv := strings.SplitN(env, "=", 2) + if matchEnvPattern(kv[0], pattern) { + argv = append(argv, "--unsetenv", kv[0]) + } + } + } + return argv +} + +// bwrapEnvSegment returns environment passthrough args. +func bwrapEnvSegment(spec EnvSpec) []string { + if spec.Mode == "" { + return unsetBlacklistedEnv() + } + + switch spec.Mode { + case EnvModeDeny: + var argv []string + for _, key := range spec.Keys { + argv = append(argv, "--unsetenv", key) + } + if len(spec.Keys) == 0 { + argv = append(argv, unsetBlacklistedEnv()...) + } + return argv + + case EnvModeAllow: + // Clear environment, inject only allow-listed keys. + argv := []string{"--clearenv"} + for _, key := range spec.Keys { + if val, ok := os.LookupEnv(key); ok { + argv = append(argv, "--setenv", key, val) + } + } + return argv + + default: + return nil + } +} + +// strictEnvBlacklist defines glob patterns stripped in strict profile. +var strictEnvBlacklist = []string{ + "*_API_KEY", "*_TOKEN", "*_SECRET", "*_PASSWORD", + "AWS_*", "ALI_*", "ALIYUN_*", "K8S_*", "KUBE_*", +} + +// matchEnvPattern performs a simple case-insensitive glob match. +func matchEnvPattern(name, pattern string) bool { + name = strings.ToUpper(name) + pattern = strings.ToUpper(pattern) + + // Wildcard-only: *TOKEN* → contains TOKEN + if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") { + mid := pattern[1 : len(pattern)-1] + return strings.Contains(name, mid) + } + // Suffix wildcard: *_TOKEN → has suffix _TOKEN + if strings.HasPrefix(pattern, "*") { + suffix := pattern[1:] + return strings.HasSuffix(name, suffix) + } + // Prefix wildcard: AWS_* → has prefix AWS_ + if strings.HasSuffix(pattern, "*") { + prefix := pattern[:len(pattern)-1] + return strings.HasPrefix(name, prefix) + } + // Exact match. + return name == pattern +} + +// Wrap rewrites cmd to execute under bwrap. +func wrapWithArgv(cmd *exec.Cmd, bwrapPath string, argv []string) { + // Prepend bwrap argv before the original command. + // argv already ends with ["--", "setpriv", ...] and the original + // cmd.Args[0] is the user command after setpriv. + userArgs := cmd.Args + cmd.Args = make([]string, 0, len(argv)+len(userArgs)) + cmd.Args = append(cmd.Args, bwrapPath) + cmd.Args = append(cmd.Args, argv...) + cmd.Args = append(cmd.Args, userArgs...) + cmd.Path = bwrapPath +} diff --git a/components/execd/pkg/isolation/bwrap_linux.go b/components/execd/pkg/isolation/bwrap_linux.go new file mode 100644 index 0000000..59e5c33 --- /dev/null +++ b/components/execd/pkg/isolation/bwrap_linux.go @@ -0,0 +1,184 @@ +//go:build linux + +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "fmt" + "os" + "os/exec" + "strconv" + + "golang.org/x/sys/unix" + + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +// bwrapPath is the path to the bwrap binary. It is discovered at startup by +// findBwrap and cached for subsequent use. +var bwrapPath string + +// seccompBPF holds pre-generated seccomp BPF bytecode, initialised once at +// startup by generateSeccompDenyBPF. +var seccompBPF []byte + +// findBwrap locates the bwrap binary. Priority order: +// +// 1. $PATH lookup — respect user-installed bwrap +// 2. /opt/opensandbox/bwrap — injected by init container alongside execd +// 3. /usr/bin/bwrap — system package (Alpine apk) +// 4. /usr/local/bin/bwrap — manual install +func findBwrap() string { + // First: respect whatever the user has in $PATH. + if path, err := exec.LookPath("bwrap"); err == nil { + return path + } + // Fall back to known locations. + for _, p := range []string{ + "/opt/opensandbox/bwrap", + "/usr/bin/bwrap", + "/usr/local/bin/bwrap", + } { + if path, err := exec.LookPath(p); err == nil { + return path + } + } + return "" +} + +// bwrapIsSetuid reports whether the resolved bwrap binary has the setuid bit +// set. The setuid build of bubblewrap does not support --disable-userns, so +// buildArgv must skip that flag in userns mode. Detected once at startup. +var bwrapIsSetuid bool + +// isSetuidBinary reports whether the file at path has the setuid bit set. +func isSetuidBinary(path string) bool { + if path == "" { + return false + } + fi, err := os.Stat(path) + if err != nil { + return false + } + return fi.Mode()&os.ModeSetuid != 0 +} + +// bwrapImpl is the Linux bwrap Isolator. +type bwrapImpl struct{} + +// NewBwrap returns a bwrap Isolator for Linux, configured by cfg. +func NewBwrap(cfg Config) Isolator { + bwrapPath = findBwrap() + bwrapIsSetuid = isSetuidBinary(bwrapPath) + + // Pre-generate seccomp BPF once at startup. + if bpf, err := generateSeccompDenyBPF(cfg.Seccomp); err != nil { + log.Warn("seccomp: failed to generate BPF: %v", err) + } else { + seccompBPF = bpf + } + + return &bwrapImpl{} +} + +func (b *bwrapImpl) Name() string { return "bwrap" } + +func (b *bwrapImpl) Available() bool { + if bwrapPath == "" { + bwrapPath = findBwrap() + } + return bwrapPath != "" +} + +func (b *bwrapImpl) Capabilities() Capabilities { + if bwrapPath == "" { + bwrapPath = findBwrap() + } + + version, err := probeBwrapVersion() + if err != nil { + version = "" + } + + return Capabilities{ + Available: bwrapPath != "", + Isolator: "bwrap", + Version: version, + Profiles: []Profile{ProfileStrict, ProfileBalanced}, + ShareNetOverridable: true, + CommitSupported: false, // Phase 2 + DiffSupported: false, // Phase 2 + PersistAvailable: false, // Phase 2 + PersistMaxBytesDefault: 2 * 1024 * 1024 * 1024, + PersistMaxBytesLimit: 8 * 1024 * 1024 * 1024, + PersistRetainDefault: 3600, + } +} + +func (b *bwrapImpl) Wrap(cmd *exec.Cmd, opts WrapOptions) error { + if bwrapPath == "" { + bwrapPath = findBwrap() + } + if bwrapPath == "" { + return fmt.Errorf("bwrap: binary not found") + } + + // Wire up seccomp BPF via memfd, if available. + var seccompFd string + if len(seccompBPF) > 0 { + fd, err := createMemfdWithData(seccompBPF) + if err != nil { + return fmt.Errorf("bwrap: seccomp memfd: %w", err) + } + // ExtraFiles are assigned fds starting at 3 in the child process. + seccompFd = strconv.Itoa(3 + len(cmd.ExtraFiles)) + cmd.ExtraFiles = append(cmd.ExtraFiles, os.NewFile(uintptr(fd), "seccomp")) + } + + argv, err := buildArgv(opts, seccompFd) + if err != nil { + for _, f := range cmd.ExtraFiles { + f.Close() + } + return fmt.Errorf("bwrap: %w", err) + } + + wrapWithArgv(cmd, bwrapPath, argv) + return nil +} + +// createMemfdWithData creates an anonymous memfd, writes data to it, and +// seeks back to the beginning. The returned fd is ready to be passed to bwrap +// via ExtraFiles. +func createMemfdWithData(data []byte) (int, error) { + fd, err := unix.MemfdCreate("seccomp", 0) + if err != nil { + return -1, fmt.Errorf("memfd_create: %w", err) + } + // Write data and seek back to 0 so bwrap reads from the start. + if _, err := unix.Write(fd, data); err != nil { + unix.Close(fd) + return -1, fmt.Errorf("write seccomp BPF: %w", err) + } + if _, err := unix.Seek(fd, 0, 0); err != nil { + unix.Close(fd) + return -1, fmt.Errorf("seek seccomp BPF: %w", err) + } + return fd, nil +} + +// Ensure bwrapImpl satisfies Isolator. +var _ Isolator = (*bwrapImpl)(nil) diff --git a/components/execd/pkg/isolation/bwrap_stub.go b/components/execd/pkg/isolation/bwrap_stub.go new file mode 100644 index 0000000..1688640 --- /dev/null +++ b/components/execd/pkg/isolation/bwrap_stub.go @@ -0,0 +1,43 @@ +//go:build !linux + +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "fmt" + "os/exec" +) + +// findBwrap returns empty string on non-Linux. +func findBwrap() string { return "" } + +// bwrapStub is the non-Linux bwrap implementation. It reports Available=false +// and fails all Wrap calls. +type bwrapStub struct{} + +// NewBwrap returns a stub on non-Linux platforms. +func NewBwrap(_ Config) Isolator { + return &bwrapStub{} +} + +func (b *bwrapStub) Name() string { return "bwrap" } +func (b *bwrapStub) Available() bool { return false } +func (b *bwrapStub) Capabilities() Capabilities { return Capabilities{Available: false} } +func (b *bwrapStub) Wrap(_ *exec.Cmd, _ WrapOptions) error { + return fmt.Errorf("bwrap: unavailable on non-Linux platform") +} + +var _ Isolator = (*bwrapStub)(nil) diff --git a/components/execd/pkg/isolation/bwrap_test.go b/components/execd/pkg/isolation/bwrap_test.go new file mode 100644 index 0000000..83e13d1 --- /dev/null +++ b/components/execd/pkg/isolation/bwrap_test.go @@ -0,0 +1,678 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package isolation + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func uidPtr(n uint32) *uint32 { return &n } + +// Argv builder tests (platform-independent) + +func TestBuildArgv_NamespaceFlags(t *testing.T) { + tests := []struct { + name string + shareNet bool + want string // substring that must appear + dontWant string // substring that must NOT appear + }{ + {"share_net=true (default)", true, "", "--unshare-net"}, + {"share_net=false", false, "--unshare-net", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := basicWrapOpts() + opts.ShareNet = tt.shareNet + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := strings.Join(argv, " ") + if tt.want != "" && !strings.Contains(s, tt.want) { + t.Errorf("argv missing %q:\n %s", tt.want, s) + } + if tt.dontWant != "" && strings.Contains(s, tt.dontWant) { + t.Errorf("argv contains %q but should not:\n %s", tt.dontWant, s) + } + }) + } +} + +func TestBuildArgv_TmpSegment(t *testing.T) { + tests := []struct { + profile Profile + want string + dont string + }{ + {ProfileStrict, "--tmpfs /tmp", "--bind /tmp /tmp"}, + {ProfileBalanced, "--bind /tmp /tmp", "--tmpfs /tmp"}, + } + + for _, tt := range tests { + t.Run(string(tt.profile), func(t *testing.T) { + opts := basicWrapOpts() + opts.Profile = tt.profile + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, tt.want) { + t.Errorf("%s: missing %q:\n %s", tt.profile, tt.want, s) + } + if tt.dont != "" && strings.Contains(s, tt.dont) { + t.Errorf("%s: should not contain %q:\n %s", tt.profile, tt.dont, s) + } + }) + } +} + +func TestBuildArgv_WorkspaceSegment(t *testing.T) { + ws := func(mode WorkspaceMode) WrapOptions { + opts := basicWrapOpts() + opts.Workspace.Mode = mode + return opts + } + + t.Run("rw", func(t *testing.T) { + argv, err := buildArgv(ws(WorkspaceRW), "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "--bind /workspace /workspace") { + t.Error(s) + } + }) + + t.Run("ro", func(t *testing.T) { + argv, err := buildArgv(ws(WorkspaceRO), "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "--ro-bind /workspace /workspace") { + t.Error(s) + } + }) + + t.Run("overlay_with_persist", func(t *testing.T) { + opts := ws(WorkspaceOverlay) + opts.UpperDir = "/var/lib/execd/isolation/abc" + opts.WorkDir = "/var/lib/execd/isolation/abc-work" + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + for _, want := range []string{ + "--overlay", + "/workspace", + "/var/lib/execd/isolation/abc", + "/var/lib/execd/isolation/abc-work", + } { + if !strings.Contains(s, want) { + t.Errorf("missing %q", want) + } + } + }) + + t.Run("overlay_without_persist_tmpfs", func(t *testing.T) { + opts := ws(WorkspaceOverlay) + opts.UpperDir = "" // tmpfs upper + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "--overlay-src") { + t.Error("missing --overlay-src") + } + }) +} + +func TestBuildArgv_EnvPassthrough(t *testing.T) { + t.Run("deny_with_keys", func(t *testing.T) { + opts := basicWrapOpts() + opts.EnvPassthrough = EnvSpec{Mode: EnvModeDeny, Keys: []string{"SECRET", "TOKEN"}} + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "--unsetenv SECRET") { + t.Error(s) + } + if !strings.Contains(s, "--unsetenv TOKEN") { + t.Error(s) + } + }) + + t.Run("allow_with_clearenv", func(t *testing.T) { + opts := basicWrapOpts() + opts.EnvPassthrough = EnvSpec{Mode: EnvModeAllow, Keys: []string{"PATH", "HOME"}} + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "--clearenv") { + t.Error("missing --clearenv") + } + }) + + t.Run("empty_mode_no_env_args", func(t *testing.T) { + opts := basicWrapOpts() + opts.EnvPassthrough = EnvSpec{} // empty mode + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if strings.Contains(s, "--clearenv") || strings.Contains(s, "--unsetenv") { + t.Error("should not have env args for empty mode") + } + }) +} + +func TestBuildArgv_ExtraWritable(t *testing.T) { + opts := basicWrapOpts() + opts.ExtraWritable = []string{"/data", "/tmp/custom"} + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + + for _, p := range opts.ExtraWritable { + // Each writable path generates "--bind $p $p" + if strings.Count(s, p) < 2 { + t.Errorf("missing bind for %q in:\n %s", p, s) + } + } +} + +func TestBuildArgv_Binds(t *testing.T) { + t.Run("rw_src_to_dest", func(t *testing.T) { + opts := basicWrapOpts() + opts.Binds = []BindMount{{Source: "/host/data", Dest: "/container/data"}} + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + assert.Contains(t, s, "--bind /host/data /container/data") + }) + + t.Run("readonly_uses_ro_bind", func(t *testing.T) { + opts := basicWrapOpts() + opts.Binds = []BindMount{{Source: "/host/ro", Dest: "/mnt/ro", ReadOnly: true}} + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + assert.Contains(t, s, "--ro-bind /host/ro /mnt/ro") + }) + + t.Run("empty_dest_defaults_to_source", func(t *testing.T) { + opts := basicWrapOpts() + opts.Binds = []BindMount{{Source: "/host/same"}} + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + assert.Contains(t, s, "--bind /host/same /host/same") + }) + + t.Run("validation_empty_source", func(t *testing.T) { + opts := basicWrapOpts() + opts.Binds = []BindMount{{Dest: "/mnt/x"}} + _, err := buildArgv(opts, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "bind.source is required") + }) + + t.Run("validation_relative_source", func(t *testing.T) { + opts := basicWrapOpts() + opts.Binds = []BindMount{{Source: "relative/path"}} + _, err := buildArgv(opts, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be an absolute path") + }) + + t.Run("validation_relative_dest", func(t *testing.T) { + opts := basicWrapOpts() + opts.Binds = []BindMount{{Source: "/host/a", Dest: "rel/dest"}} + _, err := buildArgv(opts, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be an absolute path") + }) +} + +func TestBuildArgv_Setpriv(t *testing.T) { + t.Run("default_uid_gid", func(t *testing.T) { + opts := basicWrapOpts() + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "setpriv") { + t.Error("missing setpriv") + } + if !strings.Contains(s, "--clear-groups") { + t.Error("missing --clear-groups") + } + }) + + t.Run("explicit_uid_gid", func(t *testing.T) { + opts := basicWrapOpts() + u, g := uint32(1001), uint32(1002) + opts.Uid = &u + opts.Gid = &g + argv, err := buildArgv(opts, "") + if err != nil { + t.Fatal(err) + } + s := strings.Join(argv, " ") + if !strings.Contains(s, "--reuid=1001") { + t.Error("missing --reuid=1001") + } + if !strings.Contains(s, "--regid=1002") { + t.Error("missing --regid=1002") + } + }) +} + +func TestBuildArgv_Userns(t *testing.T) { + t.Run("userns_mode_uses_unshare_user", func(t *testing.T) { + opts := basicWrapOpts() + u, g := uint32(1000), uint32(1000) + opts.Uid = &u + opts.Gid = &g + opts.UidMode = UidModeUserns + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + + assert.Contains(t, s, "--unshare-user", "userns mode must include --unshare-user") + assert.Contains(t, s, "--disable-userns", "userns mode must include --disable-userns") + assert.Contains(t, s, "--uid 1000", "userns mode must include --uid") + assert.Contains(t, s, "--gid 1000", "userns mode must include --gid") + }) + + t.Run("userns_mode_setuid_bwrap_skips_disable_userns", func(t *testing.T) { + // --disable-userns is unsupported by the setuid build of bwrap. + bwrapIsSetuid = true + defer func() { bwrapIsSetuid = false }() + + opts := basicWrapOpts() + opts.UidMode = UidModeUserns + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + + assert.Contains(t, s, "--unshare-user", "userns mode must still include --unshare-user") + assert.NotContains(t, s, "--disable-userns", "setuid bwrap must not include --disable-userns") + }) + + t.Run("userns_mode_no_setpriv", func(t *testing.T) { + opts := basicWrapOpts() + u, g := uint32(1000), uint32(1000) + opts.Uid = &u + opts.Gid = &g + opts.UidMode = UidModeUserns + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + + assert.NotContains(t, s, "setpriv", "userns mode must not include setpriv") + assert.NotContains(t, s, "--reuid", "userns mode must not include --reuid") + assert.NotContains(t, s, "--regid", "userns mode must not include --regid") + }) + + t.Run("setpriv_mode_no_unshare_user", func(t *testing.T) { + opts := basicWrapOpts() + u, g := uint32(1000), uint32(1000) + opts.Uid = &u + opts.Gid = &g + opts.UidMode = UidModeSetpriv + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + + assert.NotContains(t, s, "--unshare-user", "setpriv mode must not include --unshare-user") + assert.NotContains(t, s, "--disable-userns", "setpriv mode must not include --disable-userns") + assert.Contains(t, s, "setpriv", "setpriv mode must include setpriv") + assert.Contains(t, s, "--reuid=1000", "setpriv mode must include --reuid") + assert.Contains(t, s, "--regid=1000", "setpriv mode must include --regid") + }) + + t.Run("empty_uid_mode_defaults_to_setpriv", func(t *testing.T) { + opts := basicWrapOpts() + u, g := uint32(1000), uint32(1000) + opts.Uid = &u + opts.Gid = &g + // UidMode is empty string — should behave like setpriv. + argv, err := buildArgv(opts, "") + require.NoError(t, err) + s := strings.Join(argv, " ") + + assert.NotContains(t, s, "--unshare-user") + assert.Contains(t, s, "setpriv") + }) + + t.Run("userns_namespace_flag_order", func(t *testing.T) { + opts := basicWrapOpts() + u := uint32(1000) + opts.Uid = &u + opts.UidMode = UidModeUserns + argv, err := buildArgv(opts, "") + require.NoError(t, err) + + // --unshare-user must come before --unshare-pid (both in segment 1). + idxUser := indexOf(argv, "--unshare-user") + idxPid := indexOf(argv, "--unshare-pid") + assert.Greater(t, idxPid, idxUser, + "--unshare-user should appear before --unshare-pid") + + // --uid/--gid should come after --unshare-ipc (still in segment 1), + // before segment 2 (--ro-bind). + idxUid := indexOf(argv, "--uid") + idxRoBind := indexOf(argv, "--ro-bind") + assert.Greater(t, idxRoBind, idxUid, + "--uid should appear before --ro-bind (segment 2)") + }) +} + +func TestBuildArgv_Validation_UidMode(t *testing.T) { + opts := basicWrapOpts() + opts.UidMode = "bogus" + _, err := buildArgv(opts, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown uid mode") +} + +func TestBuildArgv_Seccomp(t *testing.T) { + opts := basicWrapOpts() + argv, err := buildArgv(opts, "3") // fd number passed to --seccomp + require.NoError(t, err) + s := strings.Join(argv, " ") + assert.Contains(t, s, "--seccomp 3", "missing seccomp fd") +} + +func TestBuildArgv_SegmentOrder(t *testing.T) { + opts := basicWrapOpts() + opts.Profile = ProfileStrict + opts.Workspace.Mode = WorkspaceOverlay + opts.UpperDir = "/tmp/upper" + opts.ExtraWritable = []string{"/data"} + opts.EnvPassthrough = EnvSpec{Mode: EnvModeDeny, Keys: []string{"TOKEN"}} + + argv, err := buildArgv(opts, "3") // fd number passed to --seccomp + if err != nil { + t.Fatal(err) + } + + // Expected segment order. We track by scanning argv for each marker + // and comparing the index of the first segment element. + type seg struct { + label string + match string // single argv element + } + order := []seg{ + {"1.ns", "--unshare-pid"}, + {"2.rootfs", "--ro-bind"}, + {"3.tmp", "/tmp"}, + {"4.run", "/run"}, + {"5.dev", "--dev"}, + {"6.proc", "--proc"}, + {"7.workspace", "--overlay-src"}, + {"8.extra_writable", "--bind"}, + {"9.env", "--unsetenv"}, + {"10.seccomp", "--seccomp"}, + {"11.setpriv", "setpriv"}, + } + + lastIdx := -1 + for _, s := range order { + idx := indexOf(argv, s.match) + if idx < 0 { + t.Errorf("segment %s (%q) not found in argv:\n %v", s.label, s.match, argv) + continue + } + if idx <= lastIdx { + t.Errorf("segment %s (%q) at %d: should be after index %d", s.label, s.match, idx, lastIdx) + } + lastIdx = idx + } +} + +func TestBuildArgv_SegmentOrder_Userns(t *testing.T) { + opts := basicWrapOpts() + opts.Profile = ProfileStrict + opts.Workspace.Mode = WorkspaceOverlay + opts.UpperDir = "/tmp/upper" + opts.ExtraWritable = []string{"/data"} + opts.EnvPassthrough = EnvSpec{Mode: EnvModeDeny, Keys: []string{"TOKEN"}} + u := uint32(1000) + opts.Uid = &u + opts.UidMode = UidModeUserns + + argv, err := buildArgv(opts, "3") + require.NoError(t, err) + + type seg struct { + label string + match string + } + // In userns mode: --unshare-user comes first, no setpriv at the end. + order := []seg{ + {"1.ns.userns", "--unshare-user"}, + {"1.ns.pid", "--unshare-pid"}, + {"2.rootfs", "--ro-bind"}, + {"3.tmp", "/tmp"}, + {"4.run", "/run"}, + {"5.dev", "--dev"}, + {"6.proc", "--proc"}, + {"7.workspace", "--overlay-src"}, + {"8.extra_writable", "--bind"}, + {"9.env", "--unsetenv"}, + {"10.seccomp", "--seccomp"}, + } + + lastIdx := -1 + for _, s := range order { + idx := indexOf(argv, s.match) + if idx < 0 { + t.Errorf("segment %s (%q) not found in argv:\n %v", s.label, s.match, argv) + continue + } + if idx <= lastIdx { + t.Errorf("segment %s (%q) at %d: should be after index %d", s.label, s.match, idx, lastIdx) + } + lastIdx = idx + } + + // setpriv must NOT appear in userns mode. + assert.Equal(t, -1, indexOf(argv, "setpriv"), "setpriv must not appear in userns mode") +} + +func TestBuildArgv_Validation(t *testing.T) { + tests := []struct { + name string + opts WrapOptions + want string + }{ + {"empty_workspace", WrapOptions{}, "workspace.path is required"}, + {"bad_profile", WrapOptions{Workspace: WorkspaceSpec{Path: "/ws", Mode: WorkspaceRW}, Profile: "bogus"}, "unknown profile"}, + {"bad_mode", WrapOptions{Profile: ProfileBalanced, Workspace: WorkspaceSpec{Path: "/ws", Mode: "bogus"}}, "unknown workspace mode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildArgv(tt.opts, "") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error %q does not contain %q", err.Error(), tt.want) + } + }) + } +} + +// Env pattern match tests + +func TestMatchEnvPattern(t *testing.T) { + tests := []struct { + testName string + envName string + pattern string + want bool + }{ + {"exact", "PATH", "PATH", true}, + {"suffix_wildcard_hit", "GITHUB_TOKEN", "*_TOKEN", true}, + {"suffix_wildcard_miss", "PATH", "*_TOKEN", false}, + {"prefix_wildcard_hit", "AWS_ACCESS_KEY_ID", "AWS_*", true}, + {"prefix_wildcard_miss", "PATH", "AWS_*", false}, + {"full_wildcard_hit", "MY_SECRET_KEY", "*SECRET*", true}, + {"full_wildcard_miss", "PATH", "*SECRET*", false}, + {"case_insensitive_exact", "PATH", "path", true}, + {"case_insensitive_pattern", "GITHUB_TOKEN", "*_token", true}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + got := matchEnvPattern(tt.envName, tt.pattern) + if got != tt.want { + t.Errorf("matchEnvPattern(%q, %q) = %v, want %v", tt.envName, tt.pattern, got, tt.want) + } + }) + } +} + +// WrapWithArgv test + +func TestWrapWithArgv(t *testing.T) { + cmd := exec.Command("bash", "-c", "echo hello") + + argv := []string{ + "--unshare-pid", "--ro-bind", "/", "/", + "--tmpfs", "/tmp", "--tmpfs", "/run", + "--dev", "/dev", "--proc", "/proc", + "--bind", "/workspace", "/workspace", + "--", "setpriv", "--reuid=1000", "--regid=1000", "--clear-groups", + } + + wrapWithArgv(cmd, "/usr/bin/bwrap", argv) + + if cmd.Path != "/usr/bin/bwrap" { + t.Errorf("Path = %q, want /usr/bin/bwrap", cmd.Path) + } + + if len(cmd.Args) < 3 { + t.Fatalf("too few args: %v", cmd.Args) + } + + if cmd.Args[0] != "/usr/bin/bwrap" { + t.Errorf("Args[0] = %q, want /usr/bin/bwrap", cmd.Args[0]) + } + + // Original command args should be at the end. + n := len(cmd.Args) + if cmd.Args[n-1] != "echo hello" || cmd.Args[n-2] != "-c" || cmd.Args[n-3] != "bash" { + t.Errorf("original args not preserved at end: %v", cmd.Args) + } +} + +// Profile / WorkspaceMode / EnvMode Valid tests + +func TestProfile_Valid(t *testing.T) { + if !ProfileStrict.Valid() { + t.Error("strict should be valid") + } + if !ProfileBalanced.Valid() { + t.Error("balanced should be valid") + } + if Profile("bogus").Valid() { + t.Error("bogus should be invalid") + } +} + +func TestWorkspaceMode_Valid(t *testing.T) { + for _, m := range []WorkspaceMode{WorkspaceRW, WorkspaceOverlay, WorkspaceRO} { + if !m.Valid() { + t.Errorf("%q should be valid", m) + } + } + if WorkspaceMode("bogus").Valid() { + t.Error("bogus should be invalid") + } +} + +func TestEnvMode_Valid(t *testing.T) { + if !EnvModeDeny.Valid() { + t.Error("deny should be valid") + } + if !EnvModeAllow.Valid() { + t.Error("allow should be valid") + } + if EnvMode("bogus").Valid() { + t.Error("bogus should be invalid") + } +} + +func TestUidMode_Valid(t *testing.T) { + if !UidModeSetpriv.Valid() { + t.Error("setpriv should be valid") + } + if !UidModeUserns.Valid() { + t.Error("userns should be valid") + } + if UidMode("bogus").Valid() { + t.Error("bogus should be invalid") + } +} + +// Helpers + +func basicWrapOpts() WrapOptions { + return WrapOptions{ + Profile: ProfileBalanced, + ShareNet: true, + Workspace: WorkspaceSpec{Path: "/workspace", Mode: WorkspaceRW}, + } +} + +func indexOf(items []string, s string) int { + for i, item := range items { + if item == s { + return i + } + } + return -1 +} + +// Ensure unused import vars don't break compilation on non-test. +var _ = fmt.Sprintf +var _ = os.Getpid +var _ = uidPtr diff --git a/components/execd/pkg/isolation/config.go b/components/execd/pkg/isolation/config.go new file mode 100644 index 0000000..ddadf0d --- /dev/null +++ b/components/execd/pkg/isolation/config.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "errors" + "fmt" + "io/fs" + "os" + + toml "github.com/pelletier/go-toml/v2" +) + +// Config holds all isolation-related settings, loaded from a TOML file. +// Missing fields fall back to DefaultConfig values. +type Config struct { + UpperRoot string `toml:"upper_root"` + UpperMaxBytes int64 `toml:"upper_max_bytes"` + DiffMaxBytes int64 `toml:"diff_max_bytes"` + AllowedWritable []string `toml:"allowed_writable"` + + // Seccomp overrides the built-in syscall denylist. When nil (i.e. the + // [seccomp] section is absent), the built-in denylist is used. When + // present, Deny completely replaces the built-in list — no merging. + Seccomp *SeccompOverride `toml:"seccomp"` +} + +// SeccompOverride specifies a custom syscall denylist that replaces the +// built-in default when present. +type SeccompOverride struct { + Deny []string `toml:"deny"` +} + +// DefaultConfig returns the built-in defaults used when no config file is +// provided or when individual fields are missing from the file. +func DefaultConfig() Config { + return Config{ + UpperRoot: "/var/lib/execd/isolation", + UpperMaxBytes: 8 * 1024 * 1024 * 1024, // 8 GiB + DiffMaxBytes: 4 * 1024 * 1024 * 1024, // 4 GiB + AllowedWritable: []string{"/workspace", "/mnt", "/media", "/data"}, + Seccomp: nil, // use built-in denylist + } +} + +// LoadConfig reads isolation configuration from a TOML file at path. +// +// - Empty path or file-not-found → DefaultConfig(), nil. +// - Existing file with invalid TOML → error. +// - Existing file → parsed values override defaults; missing fields keep +// their default values. +func LoadConfig(path string) (Config, error) { + cfg := DefaultConfig() + if path == "" { + return cfg, nil + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return cfg, nil + } + return cfg, fmt.Errorf("isolation config: read %s: %w", path, err) + } + + if err := toml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("isolation config: parse %s: %w", path, err) + } + + return cfg, nil +} diff --git a/components/execd/pkg/isolation/config_test.go b/components/execd/pkg/isolation/config_test.go new file mode 100644 index 0000000..2b1cedd --- /dev/null +++ b/components/execd/pkg/isolation/config_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + assert.Equal(t, "/var/lib/execd/isolation", cfg.UpperRoot) + assert.Equal(t, int64(8*1024*1024*1024), cfg.UpperMaxBytes) + assert.Equal(t, int64(4*1024*1024*1024), cfg.DiffMaxBytes) + assert.Equal(t, []string{"/workspace", "/mnt", "/media", "/data"}, cfg.AllowedWritable) + assert.Nil(t, cfg.Seccomp) +} + +func TestLoadConfig_EmptyPath(t *testing.T) { + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, DefaultConfig(), cfg) +} + +func TestLoadConfig_FileNotExist(t *testing.T) { + cfg, err := LoadConfig("/nonexistent/path/isolation.toml") + require.NoError(t, err) + assert.Equal(t, DefaultConfig(), cfg) +} + +func TestLoadConfig_Valid(t *testing.T) { + content := ` +upper_root = "/data/isolation" +upper_max_bytes = 1073741824 +diff_max_bytes = 536870912 +allowed_writable = ["/tmp", "/var/run"] + +[seccomp] +deny = ["mount", "ptrace"] +` + path := writeTempTOML(t, content) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + + assert.Equal(t, "/data/isolation", cfg.UpperRoot) + assert.Equal(t, int64(1073741824), cfg.UpperMaxBytes) + assert.Equal(t, int64(536870912), cfg.DiffMaxBytes) + assert.Equal(t, []string{"/tmp", "/var/run"}, cfg.AllowedWritable) + require.NotNil(t, cfg.Seccomp) + assert.Equal(t, []string{"mount", "ptrace"}, cfg.Seccomp.Deny) +} + +func TestLoadConfig_InvalidTOML(t *testing.T) { + path := writeTempTOML(t, `upper_root = [invalid`) + + _, err := LoadConfig(path) + assert.Error(t, err) + assert.Contains(t, err.Error(), "parse") +} + +func TestLoadConfig_PartialFields(t *testing.T) { + content := `upper_root = "/custom/path"` + path := writeTempTOML(t, content) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + + assert.Equal(t, "/custom/path", cfg.UpperRoot) + // Missing fields keep defaults. + assert.Equal(t, int64(8*1024*1024*1024), cfg.UpperMaxBytes) + assert.Equal(t, int64(4*1024*1024*1024), cfg.DiffMaxBytes) + // Missing allowed_writable keeps the built-in default allowlist. + assert.Equal(t, []string{"/workspace", "/mnt", "/media", "/data"}, cfg.AllowedWritable) + assert.Nil(t, cfg.Seccomp) +} + +func TestLoadConfig_SeccompReplace(t *testing.T) { + content := ` +[seccomp] +deny = ["socket", "connect"] +` + path := writeTempTOML(t, content) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.NotNil(t, cfg.Seccomp) + assert.Equal(t, []string{"socket", "connect"}, cfg.Seccomp.Deny) +} + +func TestLoadConfig_NoSeccomp(t *testing.T) { + content := `upper_root = "/data/iso"` + path := writeTempTOML(t, content) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + assert.Nil(t, cfg.Seccomp, "absent [seccomp] section should leave Seccomp nil") +} + +func TestLoadConfig_EmptySeccompDeny(t *testing.T) { + content := ` +[seccomp] +deny = [] +` + path := writeTempTOML(t, content) + + cfg, err := LoadConfig(path) + require.NoError(t, err) + require.NotNil(t, cfg.Seccomp, "[seccomp] present → non-nil even if deny is empty") + assert.Empty(t, cfg.Seccomp.Deny) +} + +func writeTempTOML(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "isolation.toml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} diff --git a/components/execd/pkg/isolation/isolator.go b/components/execd/pkg/isolation/isolator.go new file mode 100644 index 0000000..65a7a09 --- /dev/null +++ b/components/execd/pkg/isolation/isolator.go @@ -0,0 +1,147 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package isolation provides per-execution namespace isolation via bubblewrap. +package isolation + +import "os/exec" + +// Profile presets default isolation settings. +type Profile string + +const ( + ProfileStrict Profile = "strict" + ProfileBalanced Profile = "balanced" +) + +// Valid reports whether p is a known profile name. +func (p Profile) Valid() bool { + return p == ProfileStrict || p == ProfileBalanced +} + +// WorkspaceMode controls how the workspace directory is mounted into the +// isolated namespace. +type WorkspaceMode string + +const ( + WorkspaceRW WorkspaceMode = "rw" + WorkspaceOverlay WorkspaceMode = "overlay" + WorkspaceRO WorkspaceMode = "ro" +) + +// Valid reports whether m is a known workspace mode. +func (m WorkspaceMode) Valid() bool { + return m == WorkspaceRW || m == WorkspaceOverlay || m == WorkspaceRO +} + +// EnvMode controls how host environment variables are passed through to the +// isolated namespace. +type EnvMode string + +const ( + EnvModeDeny EnvMode = "deny" + EnvModeAllow EnvMode = "allow" +) + +// Valid reports whether m is a known env passthrough mode. +func (m EnvMode) Valid() bool { + return m == EnvModeDeny || m == EnvModeAllow +} + +// UidMode controls how user identity is established inside the namespace. +type UidMode string + +const ( + // UidModeSetpriv uses setpriv(1) after bwrap to drop privileges via + // real setuid/setgid. Requires CAP_SETUID/CAP_SETGID or root. + // This is the default when UidMode is empty. + UidModeSetpriv UidMode = "setpriv" + + // UidModeUserns creates a user namespace (--unshare-user) and maps the + // desired uid/gid inside it via --uid/--gid. Also passes + // --disable-userns to prevent nested user namespace creation. + // Does not require elevated privileges. + UidModeUserns UidMode = "userns" +) + +// Valid reports whether m is a known uid mode. +func (m UidMode) Valid() bool { + return m == UidModeSetpriv || m == UidModeUserns +} + +// Structs + +// WorkspaceSpec describes a workspace directory and how it is mounted. +type WorkspaceSpec struct { + Path string + Mode WorkspaceMode +} + +// EnvSpec controls environment variable passthrough into the namespace. +type EnvSpec struct { + Mode EnvMode + Keys []string // allowlist (mode=allow) or denylist (mode=deny) +} + +// BindMount describes an additional host path bind-mounted into the namespace +// with an explicit source-to-destination mapping. Unlike WrapOptions.ExtraWritable +// (which always mounts Source==Dest read-write), a BindMount may map a distinct +// destination and be mounted read-only. +type BindMount struct { + Source string // host path (required) + Dest string // mount destination; defaults to Source when empty + ReadOnly bool // true → --ro-bind; false → --bind +} + +// Capabilities describes what the isolator can and cannot do. +type Capabilities struct { + Available bool + Isolator string + Version string + Profiles []Profile + AllowedWorkspaces []string + AllowedExtraWritable []string + ShareNetOverridable bool + CommitSupported bool + DiffSupported bool + SeccompProfileSHA256 string + PersistAvailable bool + PersistMaxBytesDefault int64 + PersistMaxBytesLimit int64 + PersistRetainDefault int64 // seconds +} + +// WrapOptions configures a single isolated execution. +type WrapOptions struct { + Profile Profile + Workspace WorkspaceSpec + ExtraWritable []string + Binds []BindMount + ShareNet bool + EnvPassthrough EnvSpec + Uid, Gid *uint32 + UidMode UidMode // "" or "setpriv" → setpriv; "userns" → user namespace + UpperDir string // empty when upper is on tmpfs (persist disabled) + WorkDir string +} + +// Interface + +// Isolator wraps an *exec.Cmd in a namespace-isolated execution environment. +type Isolator interface { + Name() string + Available() bool + Capabilities() Capabilities + Wrap(cmd *exec.Cmd, opts WrapOptions) error +} diff --git a/components/execd/pkg/isolation/merged_view.go b/components/execd/pkg/isolation/merged_view.go new file mode 100644 index 0000000..5bc0b10 --- /dev/null +++ b/components/execd/pkg/isolation/merged_view.go @@ -0,0 +1,571 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/alibaba/opensandbox/execd/pkg/vfs" +) + +// MergedView provides a userspace overlay filesystem view: reads check upper +// first then fall through to lower; writes always go to upper. +// +// Limitation (overlay mode): MergedView writes to the upper directory on the +// host are NOT visible inside a running bwrap overlayfs mount. The kernel VFS +// caches directory entries at mount time; direct modifications to the upper +// directory bypass the overlay and are invisible to processes inside the +// namespace. Only the Run→API direction works (process writes go through the +// overlay to upper, MergedView reads upper on host). For bidirectional +// file exchange, use workspace mode "rw" instead of "overlay". +// Compile-time check: MergedView satisfies vfs.FS. +var _ vfs.FS = (*MergedView)(nil) + +type MergedView struct { + LowerDir string + UpperDir string + Uid, Gid uint32 + Mode WorkspaceMode +} + +// NewMergedView creates a merged view. upperDir may be empty (tmpfs). +func NewMergedView(lower, upper string, mode WorkspaceMode, uid, gid uint32) *MergedView { + return &MergedView{ + LowerDir: lower, + UpperDir: upper, + Uid: uid, + Gid: gid, + Mode: mode, + } +} + +// resolveUpper returns the upper path for a relative path. +func (m *MergedView) resolveUpper(rel string) string { + return filepath.Join(m.UpperDir, rel) +} + +// resolveLower returns the lower path for a relative path. +func (m *MergedView) resolveLower(rel string) string { + return filepath.Join(m.LowerDir, rel) +} + +// safePath validates and returns a path relative to the workspace. +// Absolute paths under LowerDir are stripped to relative; all others +// are cleaned normally. +func (m *MergedView) safePath(path string) (string, error) { + cleaned := filepath.Clean(path) + // Strip workspace prefix from absolute paths so Join works correctly. + if m.LowerDir != "" && strings.HasPrefix(cleaned, m.LowerDir+"/") { + cleaned = strings.TrimPrefix(cleaned, m.LowerDir+"/") + } else if m.LowerDir != "" && cleaned == m.LowerDir { + cleaned = "." + } + if strings.HasPrefix(cleaned, "..") { + return "", fmt.Errorf("path traversal denied: %s", path) + } + return cleaned, nil +} + +func (m *MergedView) rejectSymlink(path string) error { + // Check every existing component, not just the final element. + var check string + for _, seg := range strings.Split(filepath.Clean(path), string(filepath.Separator)) { + if seg == "" { + check = string(filepath.Separator) + continue + } + check = filepath.Join(check, seg) + info, err := os.Lstat(check) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlink target denied: %s", check) + } + } + return nil +} + +// createWhiteout creates a whiteout marker in upper to hide a lower entry. +func (m *MergedView) createWhiteout(rel string) error { + whName := filepath.Join(filepath.Dir(rel), ".wh."+filepath.Base(rel)) + whPath := m.resolveUpper(whName) + if err := os.MkdirAll(filepath.Dir(whPath), 0o755); err != nil { + return err + } + f, err := os.Create(whPath) + if err != nil { + return err + } + return f.Close() +} + +// hasWhiteout checks if a whiteout marker exists for the given relative path. +func (m *MergedView) hasWhiteout(rel string) bool { + if m.UpperDir == "" { + return false + } + whName := filepath.Join(filepath.Dir(rel), ".wh."+filepath.Base(rel)) + _, err := os.Lstat(m.resolveUpper(whName)) + return err == nil +} + +// Stat returns file info for a path. Checks upper first, then lower. +func (m *MergedView) Stat(path string) (os.FileInfo, error) { + rel, err := m.safePath(path) + if err != nil { + return nil, err + } + + if m.UpperDir != "" { + upperPath := m.resolveUpper(rel) + if err := m.rejectSymlink(upperPath); err != nil { + return nil, err + } + if info, err := os.Lstat(upperPath); err == nil { + return info, nil + } + if m.hasWhiteout(rel) { + return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrNotExist} + } + } + return os.Lstat(m.resolveLower(rel)) +} + +// ReadDir lists directory contents, merging upper and lower entries. +func (m *MergedView) ReadDir(path string) ([]os.DirEntry, error) { + rel, err := m.safePath(path) + if err != nil { + return nil, err + } + + entryMap := make(map[string]os.DirEntry) + + // Lower first. + lowerEntries, lowerErr := os.ReadDir(m.resolveLower(rel)) + for _, e := range lowerEntries { + entryMap[e.Name()] = e + } + + // Upper overlays — takes precedence over lower. + var upperErr error + if m.UpperDir != "" { + var upperEntries []os.DirEntry + upperEntries, upperErr = os.ReadDir(m.resolveUpper(rel)) + for _, e := range upperEntries { + if strings.HasPrefix(e.Name(), ".wh.") { + origName := strings.TrimPrefix(e.Name(), ".wh.") + delete(entryMap, origName) + continue + } + entryMap[e.Name()] = e + } + } + + if len(entryMap) == 0 && os.IsNotExist(lowerErr) && (m.UpperDir == "" || os.IsNotExist(upperErr)) { + return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrNotExist} + } + + entries := make([]os.DirEntry, 0, len(entryMap)) + for _, e := range entryMap { + entries = append(entries, e) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + return entries, nil +} + +// Open opens a file for reading. Checks upper first, then lower. +func (m *MergedView) Open(path string) (*os.File, error) { + rel, err := m.safePath(path) + if err != nil { + return nil, err + } + + if m.UpperDir != "" { + upperPath := m.resolveUpper(rel) + if err := m.rejectSymlink(upperPath); err != nil { + return nil, err + } + if f, err := os.Open(upperPath); err == nil { + return f, nil + } + if m.hasWhiteout(rel) { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist} + } + } + return os.Open(m.resolveLower(rel)) +} + +// ReadFile reads file content. Checks upper first, then lower. +func (m *MergedView) ReadFile(path string) ([]byte, error) { + rel, err := m.safePath(path) + if err != nil { + return nil, err + } + + if m.UpperDir != "" { + upperPath := m.resolveUpper(rel) + if err := m.rejectSymlink(upperPath); err != nil { + return nil, err + } + if data, err := os.ReadFile(upperPath); err == nil { + return data, nil + } + if m.hasWhiteout(rel) { + return nil, &os.PathError{Op: "read", Path: path, Err: os.ErrNotExist} + } + } + return os.ReadFile(m.resolveLower(rel)) +} + +// WriteFile writes data to upper directory. +// In overlay mode, files written here are visible to subsequent MergedView +// reads but NOT to processes inside the bwrap namespace (see MergedView doc). +func (m *MergedView) WriteFile(path string, data []byte, perm os.FileMode) error { + if m.Mode == WorkspaceRO { + return fmt.Errorf("write denied: workspace is read-only") + } + if m.UpperDir == "" { + return fmt.Errorf("no upper directory") + } + + rel, err := m.safePath(path) + if err != nil { + return err + } + + upperPath := m.resolveUpper(rel) + if err := m.rejectSymlink(upperPath); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(upperPath, data, perm); err != nil { + return err + } + return os.Chown(upperPath, int(m.Uid), int(m.Gid)) +} + +// WriteFileReader writes from a reader to upper directory. +func (m *MergedView) WriteFileReader(path string, r io.Reader, perm os.FileMode) (int64, error) { + if m.Mode == WorkspaceRO { + return 0, fmt.Errorf("write denied: workspace is read-only") + } + if m.UpperDir == "" { + return 0, fmt.Errorf("no upper directory") + } + + rel, err := m.safePath(path) + if err != nil { + return 0, err + } + + upperPath := m.resolveUpper(rel) + if err := m.rejectSymlink(upperPath); err != nil { + return 0, err + } + if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil { + return 0, err + } + + f, err := os.OpenFile(upperPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return 0, err + } + defer f.Close() + + n, err := io.Copy(f, r) + if err != nil { + return n, err + } + return n, os.Chown(upperPath, int(m.Uid), int(m.Gid)) +} + +// Remove deletes a file. For overlay mode, creates a whiteout to hide +// lower-only files. +func (m *MergedView) Remove(path string) error { + if m.Mode == WorkspaceRO { + return fmt.Errorf("remove denied: workspace is read-only") + } + + rel, err := m.safePath(path) + if err != nil { + return err + } + + if m.UpperDir != "" { + upperPath := m.resolveUpper(rel) + if _, err := os.Stat(upperPath); err == nil { + if err := os.Remove(upperPath); err != nil { + return err + } + // If it also exists in lower, create whiteout to hide it. + if _, err := os.Stat(m.resolveLower(rel)); err == nil { + return m.createWhiteout(rel) + } + return nil + } + } + + // File exists only in lower — create whiteout to mask it. + lowerPath := m.resolveLower(rel) + if _, err := os.Stat(lowerPath); err == nil { + if m.UpperDir == "" { + return fmt.Errorf("remove denied: no upper directory") + } + return m.createWhiteout(rel) + } + + return fs.ErrNotExist +} + +// RemoveAll deletes a path recursively. In overlay mode, removes the upper +// tree and creates a whiteout to hide any lower entry at the same path. +func (m *MergedView) RemoveAll(path string) error { + if m.Mode == WorkspaceRO { + return fmt.Errorf("remove denied: workspace is read-only") + } + + rel, err := m.safePath(path) + if err != nil { + return err + } + + if m.UpperDir == "" { + return fmt.Errorf("remove denied: no upper directory") + } + + upperPath := m.resolveUpper(rel) + if err := os.RemoveAll(upperPath); err != nil { + return err + } + + if _, err := os.Stat(m.resolveLower(rel)); err == nil { + return m.createWhiteout(rel) + } + return nil +} + +// MkdirAll creates directories in upper. +func (m *MergedView) MkdirAll(path string, perm os.FileMode) error { + if m.Mode == WorkspaceRO { + return fmt.Errorf("mkdir denied: workspace is read-only") + } + if m.UpperDir == "" { + return fmt.Errorf("no upper directory") + } + + rel, err := m.safePath(path) + if err != nil { + return err + } + upperPath := m.resolveUpper(rel) + if err := os.MkdirAll(upperPath, perm); err != nil { + return err + } + return os.Chown(upperPath, int(m.Uid), int(m.Gid)) +} + +// Rename moves a file within upper, or copies lower→upper then creates +// a whiteout to hide the lower source. +func (m *MergedView) Rename(oldPath, newPath string) error { + if m.Mode == WorkspaceRO { + return fmt.Errorf("rename denied: workspace is read-only") + } + + oldRel, err := m.safePath(oldPath) + if err != nil { + return err + } + newRel, err := m.safePath(newPath) + if err != nil { + return err + } + + if m.UpperDir == "" { + return fmt.Errorf("no upper directory") + } + + oldUpper := m.resolveUpper(oldRel) + newUpper := m.resolveUpper(newRel) + + copiedUp := false + if _, err := os.Stat(oldUpper); os.IsNotExist(err) { + data, err := os.ReadFile(m.resolveLower(oldRel)) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(oldUpper), 0o755); err != nil { + return err + } + if err := os.WriteFile(oldUpper, data, 0o644); err != nil { //nolint:gosec + return err + } + copiedUp = true + } + + if err := os.MkdirAll(filepath.Dir(newUpper), 0o755); err != nil { + return err + } + if err := os.Rename(oldUpper, newUpper); err != nil { + return err + } + + // If source existed in lower (either copied-up or both layers), + // create whiteout to hide the lower source. + if copiedUp { + return m.createWhiteout(oldRel) + } + if _, err := os.Stat(m.resolveLower(oldRel)); err == nil { + return m.createWhiteout(oldRel) + } + return nil +} + +// Chmod changes permissions on a path. Copy-up from lower if needed +// to avoid mutating the original workspace. +func (m *MergedView) Chmod(path string, mode os.FileMode) error { + if m.Mode == WorkspaceRO { + return fmt.Errorf("chmod denied: workspace is read-only") + } + + rel, err := m.safePath(path) + if err != nil { + return err + } + + if m.UpperDir != "" { + upperPath := m.resolveUpper(rel) + if _, err := os.Stat(upperPath); err == nil { + return os.Chmod(upperPath, mode) + } + // File only in lower — copy-up first to avoid mutating original. + lowerPath := m.resolveLower(rel) + data, err := os.ReadFile(lowerPath) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(upperPath, data, mode); err != nil { + return err + } + return os.Chown(upperPath, int(m.Uid), int(m.Gid)) + } + + if m.Mode == WorkspaceRW { + return os.Chmod(m.resolveLower(rel), mode) + } + return fmt.Errorf("chmod denied: no upper directory") +} + +// Search walks the merged view under root and returns matching file paths. +// Directories are excluded from results. Whiteout entries are respected. +func (m *MergedView) Search(root, pattern string) ([]string, error) { //nolint:gocognit + rootRel, err := m.safePath(root) + if err != nil { + return nil, err + } + + var results []string + seen := make(map[string]bool) + whiteouts := make(map[string]bool) + + // Collect whiteouts from upper first. + if m.UpperDir != "" { + upperRoot := m.resolveUpper(rootRel) + _ = filepath.WalkDir(upperRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // skip unreadable dirs + } + if strings.HasPrefix(d.Name(), ".wh.") { + rel, _ := filepath.Rel(m.UpperDir, p) + origName := strings.TrimPrefix(d.Name(), ".wh.") + origRel := filepath.Join(filepath.Dir(rel), origName) + whiteouts[origRel] = true + } + return nil + }) + } + + // Walk lower. + if m.LowerDir != "" { + lowerRoot := m.resolveLower(rootRel) + _ = filepath.WalkDir(lowerRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // skip unreadable dirs + } + if d.IsDir() { + return nil + } + rel, _ := filepath.Rel(m.LowerDir, p) + if whiteouts[rel] { + return nil + } + if matched, _ := filepath.Match(pattern, filepath.Base(rel)); matched { + seen[rel] = true + results = append(results, rel) + } + return nil + }) + } + + // Walk upper. + if m.UpperDir != "" { + upperRoot := m.resolveUpper(rootRel) + _ = filepath.WalkDir(upperRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // skip unreadable dirs + } + if d.IsDir() { + return nil + } + if strings.HasPrefix(d.Name(), ".wh.") { + return nil + } + rel, _ := filepath.Rel(m.UpperDir, p) + if matched, _ := filepath.Match(pattern, filepath.Base(rel)); matched && !seen[rel] { + results = append(results, rel) + } + return nil + }) + } + + sort.Strings(results) + return results, nil +} + +// ReplaceContent reads a file, replaces text, and writes to upper. +func (m *MergedView) ReplaceContent(path, old, newStr string) error { + data, err := m.ReadFile(path) + if err != nil { + return err + } + content := strings.ReplaceAll(string(data), old, newStr) + return m.WriteFile(path, []byte(content), 0o644) //nolint:gosec +} diff --git a/components/execd/pkg/isolation/merged_view_test.go b/components/execd/pkg/isolation/merged_view_test.go new file mode 100644 index 0000000..25f2c0d --- /dev/null +++ b/components/execd/pkg/isolation/merged_view_test.go @@ -0,0 +1,263 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestMergedView(t *testing.T, mode WorkspaceMode) *MergedView { + t.Helper() + lower := filepath.Join(t.TempDir(), "lower") + upper := filepath.Join(t.TempDir(), "upper") + require.NoError(t, os.MkdirAll(lower, 0o755)) + require.NoError(t, os.MkdirAll(upper, 0o755)) + return NewMergedView(lower, upper, mode, uint32(os.Getuid()), uint32(os.Getgid())) +} + +func TestMergedView_Stat_Lower(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "test.txt"), []byte("hello"), 0o644)) + + info, err := mv.Stat("test.txt") + require.NoError(t, err) + assert.Equal(t, "test.txt", info.Name()) + assert.Equal(t, int64(5), info.Size()) +} + +func TestMergedView_Stat_UpperOverrides(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "same.txt"), []byte("lower"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(mv.UpperDir, "same.txt"), []byte("upper"), 0o644)) + + info, err := mv.Stat("same.txt") + require.NoError(t, err) + assert.Equal(t, int64(5), info.Size(), "upper should override lower") +} + +func TestMergedView_ReadFile(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "data.txt"), []byte("lower-data"), 0o644)) + + data, err := mv.ReadFile("data.txt") + require.NoError(t, err) + assert.Equal(t, []byte("lower-data"), data) +} + +func TestMergedView_WriteFile(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + + err := mv.WriteFile("new.txt", []byte("session-data"), 0o644) + require.NoError(t, err) + + // Verify in upper. + data, err := os.ReadFile(filepath.Join(mv.UpperDir, "new.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("session-data"), data) + + // Verify NOT in lower. + _, err = os.ReadFile(filepath.Join(mv.LowerDir, "new.txt")) + assert.True(t, os.IsNotExist(err)) +} + +func TestMergedView_WriteFile_DenyReadOnly(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRO) + + err := mv.WriteFile("new.txt", []byte("data"), 0o644) + assert.Error(t, err) + assert.Contains(t, err.Error(), "read-only") +} + +func TestMergedView_Remove(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + + require.NoError(t, mv.WriteFile("del.txt", []byte("tmp"), 0o644)) + require.NoError(t, mv.Remove("del.txt")) + + _, err := os.Stat(filepath.Join(mv.UpperDir, "del.txt")) + assert.True(t, os.IsNotExist(err)) +} + +func TestMergedView_Remove_LowerOnly(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "lower-only.txt"), []byte("x"), 0o644)) + + err := mv.Remove("lower-only.txt") + require.NoError(t, err) + + // Whiteout should be created + whPath := filepath.Join(mv.UpperDir, ".wh.lower-only.txt") + _, err = os.Stat(whPath) + assert.NoError(t, err, "whiteout marker should exist") + + // File should no longer be visible via Stat + _, err = mv.Stat("lower-only.txt") + assert.True(t, os.IsNotExist(err)) +} + +func TestMergedView_RemoveAll(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, mv.MkdirAll("subdir", 0o755)) + require.NoError(t, mv.WriteFile("subdir/f.txt", []byte("x"), 0o644)) + + require.NoError(t, mv.RemoveAll("subdir")) + + _, err := os.Stat(filepath.Join(mv.UpperDir, "subdir")) + assert.True(t, os.IsNotExist(err)) +} + +func TestMergedView_MkdirAll(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + + require.NoError(t, mv.MkdirAll("a/b/c", 0o755)) + + info, err := os.Stat(filepath.Join(mv.UpperDir, "a", "b", "c")) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestMergedView_Rename(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, mv.WriteFile("old.txt", []byte("renamed"), 0o644)) + + require.NoError(t, mv.Rename("old.txt", "new.txt")) + + // Old gone. + _, err := mv.Stat("old.txt") + assert.True(t, os.IsNotExist(err)) + + // New exists. + data, err := mv.ReadFile("new.txt") + require.NoError(t, err) + assert.Equal(t, []byte("renamed"), data) +} + +func TestMergedView_Rename_LowerToUpper(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "src.txt"), []byte("from-lower"), 0o644)) + + require.NoError(t, mv.Rename("src.txt", "dst.txt")) + + data, err := mv.ReadFile("dst.txt") + require.NoError(t, err) + assert.Equal(t, []byte("from-lower"), data) +} + +func TestMergedView_Chmod(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, mv.WriteFile("perm.txt", []byte("x"), 0o600)) + + require.NoError(t, mv.Chmod("perm.txt", 0o755)) + + info, err := os.Stat(filepath.Join(mv.UpperDir, "perm.txt")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) +} + +func TestMergedView_ReplaceContent(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, mv.WriteFile("replace.txt", []byte("hello world"), 0o644)) + + require.NoError(t, mv.ReplaceContent("replace.txt", "world", "gopher")) + + data, err := mv.ReadFile("replace.txt") + require.NoError(t, err) + assert.Equal(t, []byte("hello gopher"), data) +} + +func TestMergedView_Search(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, mv.WriteFile("a.txt", []byte("a"), 0o644)) + require.NoError(t, mv.WriteFile("b.log", []byte("b"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "c.txt"), []byte("c"), 0o644)) + + results, err := mv.Search(".", "*.txt") + require.NoError(t, err) + assert.Len(t, results, 2) + assert.Contains(t, results, "a.txt") + assert.Contains(t, results, "c.txt") +} + +func TestMergedView_ReadDir(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, mv.WriteFile("upper.txt", []byte("u"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "lower.txt"), []byte("l"), 0o644)) + + entries, err := mv.ReadDir(".") + require.NoError(t, err) + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + assert.Contains(t, names, "upper.txt") + assert.Contains(t, names, "lower.txt") +} + +func TestMergedView_ReadDir_Whiteout(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + require.NoError(t, os.WriteFile(filepath.Join(mv.LowerDir, "hidden.txt"), []byte("secret"), 0o644)) + // Create whiteout file in upper to hide lower entry. + require.NoError(t, os.WriteFile(filepath.Join(mv.UpperDir, ".wh.hidden.txt"), nil, 0o644)) + + entries, err := mv.ReadDir(".") + require.NoError(t, err) + for _, e := range entries { + assert.NotEqual(t, "hidden.txt", e.Name(), "whiteout should hide lower entry") + } +} + +func TestMergedView_WriteFileReader(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + + n, err := mv.WriteFileReader("stream.txt", strings.NewReader("streamed-data"), 0o644) + require.NoError(t, err) + assert.Equal(t, int64(13), n) + + data, err := mv.ReadFile("stream.txt") + require.NoError(t, err) + assert.Equal(t, []byte("streamed-data"), data) +} + +func TestMergedView_PathTraversal(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRW) + + _, err := mv.Stat("../etc/passwd") + assert.Error(t, err) + assert.Contains(t, err.Error(), "traversal") + + _, err = mv.ReadFile("../../host") + assert.Error(t, err) + assert.Contains(t, err.Error(), "traversal") + + err = mv.WriteFile("../escape", []byte("x"), 0o644) + assert.Error(t, err) + assert.Contains(t, err.Error(), "traversal") +} + +func TestMergedView_ReadOnly_AllWritesDenied(t *testing.T) { + mv := newTestMergedView(t, WorkspaceRO) + + assert.Error(t, mv.WriteFile("x.txt", []byte("x"), 0o644)) + assert.Error(t, mv.Remove("x.txt")) + assert.Error(t, mv.MkdirAll("d", 0o755)) + assert.Error(t, mv.Rename("a", "b")) + assert.Error(t, mv.Chmod("x.txt", 0o755)) +} diff --git a/components/execd/pkg/isolation/probe.go b/components/execd/pkg/isolation/probe.go new file mode 100644 index 0000000..befa962 --- /dev/null +++ b/components/execd/pkg/isolation/probe.go @@ -0,0 +1,183 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +// ProbeResult holds the result of startup isolation probing. +type ProbeResult struct { + Available bool + Isolator string + Version string + Message string // diagnostic message when unavailable + CommitSupported bool // Phase 2 + DiffSupported bool // Phase 2 + PersistAvailable bool // Phase 2 — requires emptyDir +} + +// ProbeConfig controls Probe behaviour. +type ProbeConfig struct { + UpperRoot string + UpperMaxBytes int64 +} + +// Probe runs startup detection. Returns a ProbeResult describing what +// isolation capabilities are available in the current environment. +// +// On Linux with working bwrap: +// +// Available=true, Isolator="bwrap", Version="0.10.0" +// +// Otherwise: +// +// Available=false +func Probe(cfg ProbeConfig) ProbeResult { + result := ProbeResult{} + + // Check if bwrap binary is available. + version, err := probeBwrapVersion() + if err != nil { + result.Message = fmt.Sprintf("bwrap not found: %v (searched: $PATH, /opt/opensandbox/bwrap, /usr/bin/bwrap, /usr/local/bin/bwrap)", err) + log.Warn("isolation probe: %s", result.Message) + return result + } + + result.Available = true + result.Isolator = "bwrap" + result.Version = version + + // Smoke test: verify bwrap can actually create a namespace. + if err := probeBwrapSmoke(); err != nil { + result.Message = fmt.Sprintf("bwrap found (v%s) but smoke test failed: %v", version, err) + log.Warn("isolation probe: %s", result.Message) + result.Available = false + return result + } + + if probeOverlayMount(cfg.UpperRoot) { + result.CommitSupported = true + result.DiffSupported = true + } + + return result +} + +// probeBwrapVersion returns the bwrap version string if available. +func probeBwrapVersion() (string, error) { + p := findBwrap() + if p == "" { + return "", fmt.Errorf("bwrap not found") + } + + var stdout bytes.Buffer + cmd := exec.Command(p, "--version") + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } + + // bwrap prints version to stdout, e.g.: + // "bubblewrap 0.8.0" or "bwrap 0.10.0" + out := stdout.String() + return parseBwrapVersion(out), nil +} + +var bwrapVersionRe = regexp.MustCompile(`b(?:ubble)?wrap\s+(\d+\.\d+\.\d+)`) + +// parseBwrapVersion extracts the version number from bwrap --version output. +func parseBwrapVersion(out string) string { + match := bwrapVersionRe.FindStringSubmatch(out) + if len(match) < 2 { + return "" + } + return match[1] +} + +// probeBwrapSmoke verifies bwrap can create a minimal namespace. +func probeBwrapSmoke() error { + p := findBwrap() + if p == "" { + return fmt.Errorf("bwrap not found") + } + cmd := exec.Command(p, + "--unshare-pid", "--unshare-uts", "--unshare-ipc", "--unshare-cgroup", + "--ro-bind", "/", "/", + "--proc", "/proc", + "--", "true", + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("bwrap smoke test failed: %w (stderr: %s)", err, strings.TrimSpace(stderr.String())) + } + return nil +} + +// probeOverlayMount tests whether bwrap can create an overlay mount. +func probeOverlayMount(upperRoot string) bool { + p := findBwrap() + if p == "" { + return false + } + + // Probe on the upper root filesystem (typically tmpfs/emptyDir) rather + // than /tmp, because overlayfs cannot nest on Docker's overlay2 layer + // but works fine on tmpfs. + base := upperRoot + if base == "" { + base = os.TempDir() + } + tmpDir, err := os.MkdirTemp(base, "execd-probe-overlay-*") + if err != nil { + log.Warn("isolation probe: overlay: MkdirTemp(%s): %v", base, err) + return false + } + defer os.RemoveAll(tmpDir) + + lowerDir := filepath.Join(tmpDir, "lower") + upperDir := filepath.Join(tmpDir, "upper") + workDir := filepath.Join(tmpDir, "work") + for _, d := range []string{lowerDir, upperDir, workDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + log.Warn("isolation probe: overlay: MkdirAll(%s): %v", d, err) + return false + } + } + + cmd := exec.Command(p, + "--ro-bind", "/", "/", + "--proc", "/proc", + "--overlay-src", lowerDir, + "--overlay", upperDir, workDir, "/mnt", + "--", "true", + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + log.Warn("isolation probe: overlay mount failed: %v (stderr: %s)", err, strings.TrimSpace(stderr.String())) + return false + } + return true +} diff --git a/components/execd/pkg/isolation/probe_test.go b/components/execd/pkg/isolation/probe_test.go new file mode 100644 index 0000000..9dc7a0b --- /dev/null +++ b/components/execd/pkg/isolation/probe_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "testing" +) + +func TestParseBwrapVersion(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"bubblewrap 0.8.0\n", "0.8.0"}, + {"bwrap 0.10.0\n", "0.10.0"}, + {"bwrap: unrecognized option '--version'\n", ""}, + {"", ""}, + {"some unrelated output\nbubblewrap 0.11.2-dev\nmore output", "0.11.2"}, + } + + for _, tt := range tests { + got := parseBwrapVersion(tt.in) + if got != tt.want { + t.Errorf("parseBwrapVersion(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestProbeConfigDefaults(t *testing.T) { + cfg := ProbeConfig{ + UpperRoot: "/var/lib/execd/isolation", + UpperMaxBytes: 8 * 1024 * 1024 * 1024, + } + if cfg.UpperRoot == "" { + t.Error("UpperRoot should not be empty") + } +} + +func TestProbeResult_Defaults(t *testing.T) { + result := ProbeResult{} + if result.Available { + t.Error("default ProbeResult should have Available=false") + } + if result.CommitSupported { + t.Error("default ProbeResult should have CommitSupported=false") + } + if result.DiffSupported { + t.Error("default ProbeResult should have DiffSupported=false") + } +} diff --git a/components/execd/pkg/isolation/seccomp_gen.go b/components/execd/pkg/isolation/seccomp_gen.go new file mode 100644 index 0000000..099ba5e --- /dev/null +++ b/components/execd/pkg/isolation/seccomp_gen.go @@ -0,0 +1,143 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package isolation + +import ( + "fmt" + "syscall" + + seccomp "github.com/elastic/go-seccomp-bpf" + "github.com/elastic/go-seccomp-bpf/arch" + "golang.org/x/net/bpf" +) + +// denylistSyscalls lists syscall names to block. Syscalls not present on the +// current architecture are silently skipped. +var denylistSyscalls = []string{ + // Filesystem manipulation + "mount", "umount2", "chroot", "pivot_root", + + // Process introspection / manipulation + "ptrace", "process_vm_readv", "process_vm_writev", + "kcmp", + + // Kernel module loading + "init_module", "finit_module", "delete_module", + + // BPF / seccomp manipulation + "bpf", "seccomp", + + // Execution domain + "personality", + + // Privilege UID/GID changes omitted: setpriv needs setresuid/setresgid + // to drop from root to the requested UID/GID. After the drop, processes + // have no CAP_SETUID and cannot regain privileges. + + // Kernel key management + "add_key", "request_key", "keyctl", + + // I/O privilege + "iopl", "ioperm", + + // System state + "reboot", "syslog", + "swapon", "swapoff", + + // Namespace manipulation (already in bwrap namespace) + "setns", "unshare", + + // Handle-based operations + "name_to_handle_at", "open_by_handle_at", + + // Other potentially dangerous + "userfaultfd", + "kexec_load", "kexec_file_load", + "acct", +} + +// generateSeccompDenyBPF returns BPF bytecode for a default-allow, +// deny-listed syscall filter. The returned bytes are in struct sock_filter +// format (8 bytes per instruction, native endian). +// +// When override is nil the built-in denylistSyscalls is used. When non-nil, +// override.Deny completely replaces the built-in list. +func generateSeccompDenyBPF(override *SeccompOverride) ([]byte, error) { + archInfo, err := arch.GetInfo("") + if err != nil { + return nil, fmt.Errorf("seccomp: detect arch: %w", err) + } + + denylist := denylistSyscalls + if override != nil { + denylist = override.Deny + } + + // Filter denylist to syscalls that exist on this architecture. + names := filterKnownSyscalls(archInfo, denylist) + if len(names) == 0 { + return nil, nil + } + + policy := seccomp.Policy{ + DefaultAction: seccomp.ActionAllow, + Syscalls: []seccomp.SyscallGroup{ + { + Names: names, + Action: seccomp.ActionErrno | seccomp.Action(syscall.EACCES), + }, + }, + } + + instructions, err := policy.Assemble() + if err != nil { + return nil, fmt.Errorf("seccomp: assemble policy: %w", err) + } + + raw, err := bpf.Assemble(instructions) + if err != nil { + return nil, fmt.Errorf("seccomp: assemble BPF: %w", err) + } + + // Serialize to struct sock_filter bytes (8 bytes per instruction). + // Little-endian since amd64/arm64 targets are LE. + buf := make([]byte, len(raw)*8) + for i, ri := range raw { + off := i * 8 + buf[off] = byte(ri.Op) + buf[off+1] = byte(ri.Op >> 8) + buf[off+2] = ri.Jt + buf[off+3] = ri.Jf + buf[off+4] = byte(ri.K) + buf[off+5] = byte(ri.K >> 8) + buf[off+6] = byte(ri.K >> 16) + buf[off+7] = byte(ri.K >> 24) + } + + return buf, nil +} + +// filterKnownSyscalls returns names that exist in the architecture's table. +func filterKnownSyscalls(archInfo *arch.Info, names []string) []string { + out := make([]string, 0, len(names)) + for _, name := range names { + if _, ok := archInfo.SyscallNames[name]; ok { + out = append(out, name) + } + } + return out +} diff --git a/components/execd/pkg/isolation/seccomp_gen_test.go b/components/execd/pkg/isolation/seccomp_gen_test.go new file mode 100644 index 0000000..9b3c4c8 --- /dev/null +++ b/components/execd/pkg/isolation/seccomp_gen_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package isolation + +import ( + "testing" + + "github.com/elastic/go-seccomp-bpf/arch" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateSeccompDenyBPF_Default(t *testing.T) { + bpf, err := generateSeccompDenyBPF(nil) + require.NoError(t, err, "BPF generation should succeed on Linux") + require.NotEmpty(t, bpf, "BPF bytecode should not be empty") + + // Each struct sock_filter entry is 8 bytes. + assert.True(t, len(bpf) >= 8, "BPF bytecode should contain at least 1 instruction (%d bytes)", len(bpf)) + assert.Equal(t, 0, len(bpf)%8, "BPF bytecode length must be multiple of 8, got %d", len(bpf)) + + // First instruction should be a load-absolute at the arch offset (4 bytes), + // BPF_LD+BPF_W+BPF_ABS = 0x20. + // Op is little-endian uint16: low byte = 0x20, high byte = 0x00 → 0x0020. + op := uint16(bpf[0]) | uint16(bpf[1])<<8 + assert.Equal(t, uint16(0x20), op, "first instruction Op should be BPF_LD|BPF_W|BPF_ABS (0x20), got 0x%04x", op) +} + +func TestGenerateSeccompDenyBPF_Override(t *testing.T) { + override := &SeccompOverride{Deny: []string{"mount", "ptrace"}} + bpf, err := generateSeccompDenyBPF(override) + require.NoError(t, err) + require.NotEmpty(t, bpf) + assert.Equal(t, 0, len(bpf)%8) + + // Override with fewer syscalls should produce smaller BPF than default. + defaultBPF, err := generateSeccompDenyBPF(nil) + require.NoError(t, err) + assert.Less(t, len(bpf), len(defaultBPF), "override with 2 syscalls should produce smaller BPF than default") +} + +func TestGenerateSeccompDenyBPF_EmptyOverride(t *testing.T) { + override := &SeccompOverride{Deny: []string{}} + bpf, err := generateSeccompDenyBPF(override) + require.NoError(t, err) + assert.Nil(t, bpf, "empty deny list should produce nil BPF") +} + +func TestGenerateSeccompDenyBPF_ArchSpecific(t *testing.T) { + bpf, err := generateSeccompDenyBPF(nil) + require.NoError(t, err) + t.Logf("generated %d BPF instructions (%d bytes)", len(bpf)/8, len(bpf)) +} + +func TestFilterKnownSyscalls(t *testing.T) { + // Use real arch info to test known vs unknown filtering. + archInfo, err := arch.GetInfo("") + require.NoError(t, err) + + names := []string{"open", "read", "nonexistent_syscall_xyz"} + filtered := filterKnownSyscalls(archInfo, names) + assert.NotContains(t, filtered, "nonexistent_syscall_xyz", "unknown syscall should be filtered out") + assert.Contains(t, filtered, "open", "open should be present on all arches") + assert.Contains(t, filtered, "read", "read should be present on all arches") +} diff --git a/components/execd/pkg/isolation/upper.go b/components/execd/pkg/isolation/upper.go new file mode 100644 index 0000000..a3380ea --- /dev/null +++ b/components/execd/pkg/isolation/upper.go @@ -0,0 +1,192 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +// UpperManager manages upper directories for overlay workspaces. +type UpperManager struct { + root string + maxBytes int64 + mu sync.Mutex + entries map[string]*UpperEntry +} + +// UpperEntry tracks one allocated upper directory. +type UpperEntry struct { + UpperDir string + WorkDir string + InUse bool +} + +// NewUpperManager creates an upper directory manager. +func NewUpperManager(root string, maxBytes int64) (*UpperManager, error) { + if root == "" { + return nil, errors.New("upper: root path is required") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("upper: create root %s: %w", root, err) + } + return &UpperManager{ + root: root, + maxBytes: maxBytes, + entries: make(map[string]*UpperEntry), + }, nil +} + +// ErrUpperLimitExceeded is returned when the upper directory size limit is exceeded. +var ErrUpperLimitExceeded = errors.New("upper: total usage exceeds configured limit") + +// Allocate creates a new upper + work directory pair. Returns the session ID +// and the directories. Returns ErrUpperLimitExceeded if maxBytes > 0 and +// current usage already meets or exceeds the limit. +func (m *UpperManager) Allocate() (sessionID, upperDir, workDir string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.maxBytes > 0 { + usage, usageErr := m.usageLocked() + if usageErr == nil && usage >= m.maxBytes { + return "", "", "", fmt.Errorf("%w: %d >= %d bytes", ErrUpperLimitExceeded, usage, m.maxBytes) + } + } + + id := newSessionID() + upperDir = filepath.Join(m.root, id, "upper") + workDir = filepath.Join(m.root, id, "work") + + if err := os.MkdirAll(upperDir, 0o755); err != nil { + return "", "", "", fmt.Errorf("upper: mkdir %s: %w", upperDir, err) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + os.RemoveAll(filepath.Dir(upperDir)) + return "", "", "", fmt.Errorf("upper: mkdir %s: %w", workDir, err) + } + + m.entries[id] = &UpperEntry{ + UpperDir: upperDir, + WorkDir: workDir, + InUse: true, + } + + return id, upperDir, workDir, nil +} + +// Release marks an upper directory as available for GC. +func (m *UpperManager) Release(sessionID string) { + m.mu.Lock() + defer m.mu.Unlock() + + if e, ok := m.entries[sessionID]; ok { + e.InUse = false + } +} + +// Remove immediately deletes an upper directory. +func (m *UpperManager) Remove(sessionID string) error { + m.mu.Lock() + e, ok := m.entries[sessionID] + if !ok { + m.mu.Unlock() + return fmt.Errorf("upper: session %s not found", sessionID) + } + delete(m.entries, sessionID) + m.mu.Unlock() + + upperParent := filepath.Dir(e.UpperDir) + return os.RemoveAll(upperParent) +} + +// Collect runs one garbage collection pass, removing all released entries. +func (m *UpperManager) Collect() []string { + m.mu.Lock() + defer m.mu.Unlock() + + var freed []string + for id, e := range m.entries { + if !e.InUse { + upperParent := filepath.Dir(e.UpperDir) + if err := os.RemoveAll(upperParent); err == nil { + freed = append(freed, id) + delete(m.entries, id) + } + } + } + return freed +} + +// Usage returns the current total size of all upper directories in bytes. +func (m *UpperManager) Usage() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.usageLocked() +} + +// usageLocked calculates usage without acquiring the mutex. Caller must hold m.mu. +func (m *UpperManager) usageLocked() (int64, error) { + var total int64 + for _, e := range m.entries { + size, err := dirSize(e.UpperDir) + if err != nil { + return 0, err + } + total += size + } + return total, nil +} + +// Root returns the manager's root path. +func (m *UpperManager) Root() string { + return m.root +} + +// MaxBytes returns the configured byte limit. +func (m *UpperManager) MaxBytes() int64 { + return m.maxBytes +} + +// newSessionID generates a random hex session ID. +func newSessionID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Cryptographic randomness shouldn't fail. Fall back to a + // timestamp-based name as last resort. + return fmt.Sprintf("fallback-%d", os.Getpid()) + } + return hex.EncodeToString(b[:]) +} + +// dirSize walks a directory and returns total bytes used. +func dirSize(path string) (int64, error) { + var size int64 + err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + size += info.Size() + } + return nil + }) + return size, err +} diff --git a/components/execd/pkg/isolation/upper_test.go b/components/execd/pkg/isolation/upper_test.go new file mode 100644 index 0000000..726631e --- /dev/null +++ b/components/execd/pkg/isolation/upper_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package isolation + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestNewUpperManager(t *testing.T) { + dir := t.TempDir() + root := filepath.Join(dir, "isolation") + + mgr, err := NewUpperManager(root, 8<<30) + if err != nil { + t.Fatal(err) + } + if mgr.Root() != root { + t.Errorf("Root() = %q, want %q", mgr.Root(), root) + } + if mgr.MaxBytes() != 8<<30 { + t.Errorf("MaxBytes() = %d, want %d", mgr.MaxBytes(), 8<<30) + } + + // Verify directory was created. + if _, err := os.Stat(root); os.IsNotExist(err) { + t.Error("root dir not created") + } +} + +func TestNewUpperManager_EmptyRoot(t *testing.T) { + _, err := NewUpperManager("", 0) + if err == nil { + t.Error("expected error for empty root") + } +} + +func TestUpperManager_Allocate(t *testing.T) { + mgr := newTestUpperManager(t) + + id1, upper1, work1, err := mgr.Allocate() + if err != nil { + t.Fatal(err) + } + if id1 == "" { + t.Error("empty session ID") + } + if upper1 == "" || work1 == "" { + t.Error("empty directories") + } + + // Verify directories exist. + for _, p := range []string{upper1, work1} { + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("directory %s not created", p) + } + } + + // Verify entries tracked. + mgr.mu.Lock() + e := mgr.entries[id1] + mgr.mu.Unlock() + if e == nil { + t.Fatal("entry not tracked") + } + if !e.InUse { + t.Error("entry should be InUse after allocation") + } +} + +func TestUpperManager_AllocateUnique(t *testing.T) { + mgr := newTestUpperManager(t) + + id1, _, _, _ := mgr.Allocate() + id2, _, _, _ := mgr.Allocate() + if id1 == id2 { + t.Error("session IDs should be unique") + } +} + +func TestUpperManager_Release(t *testing.T) { + mgr := newTestUpperManager(t) + id, upper, work, _ := mgr.Allocate() + + mgr.Release(id) + + mgr.mu.Lock() + e := mgr.entries[id] + mgr.mu.Unlock() + if e.InUse { + t.Error("entry should not be InUse after release") + } + + // Directories should still exist (GC removes them). + for _, p := range []string{upper, work} { + if _, err := os.Stat(p); os.IsNotExist(err) { + t.Errorf("directory %s removed prematurely", p) + } + } +} + +func TestUpperManager_Remove(t *testing.T) { + mgr := newTestUpperManager(t) + id, upper, _, _ := mgr.Allocate() + + if err := mgr.Remove(id); err != nil { + t.Fatal(err) + } + + // Upper parent should be gone. + upperParent := filepath.Dir(upper) + if _, err := os.Stat(upperParent); !os.IsNotExist(err) { + t.Error("upper parent should be removed") + } + + mgr.mu.Lock() + _, ok := mgr.entries[id] + mgr.mu.Unlock() + if ok { + t.Error("entry should be removed from map") + } +} + +func TestUpperManager_RemoveMissing(t *testing.T) { + mgr := newTestUpperManager(t) + if err := mgr.Remove("nonexistent"); err == nil { + t.Error("expected error for nonexistent session") + } +} + +func TestUpperManager_Collect(t *testing.T) { + mgr := newTestUpperManager(t) + + id1, upper1, _, _ := mgr.Allocate() + _, upper2, _, _ := mgr.Allocate() + + mgr.Release(id1) + // id2 stays InUse. + + freed := mgr.Collect() + if len(freed) != 1 { + t.Fatalf("Collect freed %d entries, want 1", len(freed)) + } + if freed[0] != id1 { + t.Errorf("freed id = %q, want %q", freed[0], id1) + } + + // Freed directory should be gone. + upperParent1 := filepath.Dir(upper1) + if _, err := os.Stat(upperParent1); !os.IsNotExist(err) { + t.Error("freed upper should be removed") + } + + // Non-freed directory should still exist. + if _, err := os.Stat(upper2); os.IsNotExist(err) { + t.Error("in-use upper should not be removed") + } +} + +func TestUpperManager_Usage(t *testing.T) { + mgr := newTestUpperManager(t) + + // Empty usage. + usage, err := mgr.Usage() + if err != nil { + t.Fatal(err) + } + if usage != 0 { + t.Errorf("empty usage = %d, want 0", usage) + } + + // Allocate and write a file. + _, upper, _, _ := mgr.Allocate() + if err := os.WriteFile(filepath.Join(upper, "test.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + usage, err = mgr.Usage() + if err != nil { + t.Fatal(err) + } + if usage < 5 { + t.Errorf("usage = %d, want at least 5", usage) + } +} + +func TestDirSize(t *testing.T) { + dir := t.TempDir() + + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + + size, err := dirSize(dir) + if err != nil { + t.Fatal(err) + } + if size < 10 { // 5 + 5 + t.Errorf("dirSize = %d, want at least 10", size) + } +} + +func TestNewSessionID(t *testing.T) { + id := newSessionID() + if len(id) != 32 { + t.Errorf("session ID length = %d, want 32", len(id)) + } +} + +func TestUpperManager_AllocateLimitExceeded(t *testing.T) { + root := filepath.Join(t.TempDir(), "isolation") + mgr, err := NewUpperManager(root, 100) + if err != nil { + t.Fatal(err) + } + + // Allocate and write enough data to exceed the 100-byte limit. + _, upper, _, err := mgr.Allocate() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(upper, "big.bin"), make([]byte, 200), 0o644); err != nil { + t.Fatal(err) + } + + // Next allocation should fail. + _, _, _, err = mgr.Allocate() + if err == nil { + t.Fatal("expected error when upper limit exceeded") + } + if !errors.Is(err, ErrUpperLimitExceeded) { + t.Errorf("got %v, want ErrUpperLimitExceeded", err) + } +} + +func TestUpperManager_AllocateNoLimitWhenZero(t *testing.T) { + root := filepath.Join(t.TempDir(), "isolation") + mgr, err := NewUpperManager(root, 0) + if err != nil { + t.Fatal(err) + } + + // maxBytes=0 means no limit. Allocation should always succeed. + _, upper, _, err := mgr.Allocate() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(upper, "big.bin"), make([]byte, 1000), 0o644); err != nil { + t.Fatal(err) + } + + _, _, _, err = mgr.Allocate() + if err != nil { + t.Errorf("unexpected error with maxBytes=0: %v", err) + } +} + +// Helpers + +func newTestUpperManager(t *testing.T) *UpperManager { + t.Helper() + root := filepath.Join(t.TempDir(), "isolation") + mgr, err := NewUpperManager(root, 8<<30) + if err != nil { + t.Fatal(err) + } + return mgr +} diff --git a/components/execd/pkg/jupyter/auth/auth.go b/components/execd/pkg/jupyter/auth/auth.go new file mode 100644 index 0000000..70bee6f --- /dev/null +++ b/components/execd/pkg/jupyter/auth/auth.go @@ -0,0 +1,33 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +// Auth represents authentication configuration. +type Auth struct { + Token string + Username string + Password string +} + +// Validate reports which auth mode is configured. +func (a *Auth) Validate() string { + if a.Token != "" { + return "token" + } + if a.Username != "" { + return "basic" + } + return "none" +} diff --git a/components/execd/pkg/jupyter/auth/auth_test.go b/components/execd/pkg/jupyter/auth/auth_test.go new file mode 100644 index 0000000..3affb92 --- /dev/null +++ b/components/execd/pkg/jupyter/auth/auth_test.go @@ -0,0 +1,121 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestTokenAuthentication(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + expectedToken := "token test-token" + if token != expectedToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + auth := NewAuth() + auth.Token = "test-token" + + client := NewClient(&http.Client{}, auth) + + req, err := http.NewRequest("GET", server.URL, nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } +} + +func TestBasicAuthentication(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "testuser" || password != "testpass" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + auth := NewAuth() + auth.Username = "testuser" + auth.Password = "testpass" + + client := NewClient(&http.Client{}, auth) + + req, err := http.NewRequest("GET", server.URL, nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) + } +} + +func TestAuthValidation(t *testing.T) { + emptyAuth := NewAuth() + if emptyAuth.IsValid() { + t.Error("Empty Auth should be invalid, but was determined to be valid") + } + + tokenAuth := NewAuth() + tokenAuth.Token = "test-token" + if !tokenAuth.IsValid() { + t.Error("Auth with token should be valid, but was determined to be invalid") + } + + basicAuth := NewAuth() + basicAuth.Username = "testuser" + basicAuth.Password = "testpass" + if !basicAuth.IsValid() { + t.Error("Auth with Basic Auth should be valid, but was determined to be invalid") + } + + invalidBasicAuth := NewAuth() + invalidBasicAuth.Username = "testuser" + if invalidBasicAuth.IsValid() { + t.Error("Auth with only username and no password should be invalid, but was determined to be valid") + } + + mixedAuth := NewAuth() + mixedAuth.Token = "test-token" + mixedAuth.Username = "testuser" + mixedAuth.Password = "testpass" + if !mixedAuth.IsValid() { + t.Error("Auth with both token and Basic Auth should be valid, but was determined to be invalid") + } +} diff --git a/components/execd/pkg/jupyter/auth/client.go b/components/execd/pkg/jupyter/auth/client.go new file mode 100644 index 0000000..bc61723 --- /dev/null +++ b/components/execd/pkg/jupyter/auth/client.go @@ -0,0 +1,88 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "fmt" + "io" + "net/http" +) + +// Client wraps http.Client and injects auth headers. +type Client struct { + httpClient *http.Client + auth *Auth +} + +// NewClient creates a new authenticated HTTP client. +func NewClient(httpClient *http.Client, auth *Auth) *Client { + return &Client{ + httpClient: httpClient, + auth: auth, + } +} + +// Do sends an HTTP request and automatically adds authentication data. +func (c *Client) Do(req *http.Request) (*http.Response, error) { + if c.auth == nil { + return c.httpClient.Do(req) + } + + if c.auth.Token != "" { + req.Header.Set("Authorization", fmt.Sprintf("token %s", c.auth.Token)) + } else if c.auth.Username != "" { + req.SetBasicAuth(c.auth.Username, c.auth.Password) + } + + return c.httpClient.Do(req) +} + +// Get sends a GET request. +func (c *Client) Get(url string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + return c.Do(req) +} + +// Post sends a POST request. +func (c *Client) Post(url, contentType string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + return c.Do(req) +} + +// Put sends a PUT request. +func (c *Client) Put(url, contentType string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPut, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", contentType) + return c.Do(req) +} + +// Delete sends a DELETE request. +func (c *Client) Delete(url string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return nil, err + } + return c.Do(req) +} diff --git a/components/execd/pkg/jupyter/auth/types.go b/components/execd/pkg/jupyter/auth/types.go new file mode 100644 index 0000000..4d019ae --- /dev/null +++ b/components/execd/pkg/jupyter/auth/types.go @@ -0,0 +1,34 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +const ( + AuthTypeNone = "none" + AuthTypeToken = "token" + AuthTypeBasic = "basic" + AuthHeaderKey = "Authorization" + AuthHeaderValuePrefix = "token " + AuthURLParamKey = "token" +) + +// NewAuth creates an empty authentication configuration. +func NewAuth() *Auth { + return &Auth{} +} + +// IsValid reports whether token or username/password are present. +func (a *Auth) IsValid() bool { + return a.Token != "" || (a.Username != "" && a.Password != "") +} diff --git a/components/execd/pkg/jupyter/client.go b/components/execd/pkg/jupyter/client.go new file mode 100644 index 0000000..978c574 --- /dev/null +++ b/components/execd/pkg/jupyter/client.go @@ -0,0 +1,175 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jupyter + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/auth" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/kernel" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/session" +) + +// Client interacts with the Jupyter server. +type Client struct { + BaseURL string + httpClient *http.Client + Auth *auth.Auth + kernelClient *kernel.Client + sessionClient *session.Client + executeClient *execute.Client + authClient *auth.Client +} + +type ClientOption func(*Client) + +// WithHTTPClient sets a custom HTTP client. +func WithHTTPClient(client *http.Client) ClientOption { + return func(c *Client) { + c.httpClient = client + } +} + +// WithToken configures the client with an authentication token. +func WithToken(token string) ClientOption { + return func(c *Client) { + c.Auth.Token = token + } +} + +// NewClient creates a new Jupyter client instance. +func NewClient(baseURL string, options ...ClientOption) *Client { + client := &Client{ + BaseURL: baseURL, + httpClient: http.DefaultClient, + Auth: auth.NewAuth(), + } + + for _, option := range options { + option(client) + } + + client.authClient = auth.NewClient(client.httpClient, client.Auth) + + client.kernelClient = kernel.NewClient(baseURL, client.httpClient) + client.sessionClient = session.NewClient(baseURL, client.httpClient) + client.executeClient = execute.NewClient(baseURL, client.authClient) + + return client +} + +// SetToken configures token authentication. +func (c *Client) SetToken(token string) { + c.Auth.Token = token +} + +// ValidateAuth quickly checks that some auth data is present. +func (c *Client) ValidateAuth() (string, error) { + authType := c.Auth.Validate() + if authType == "none" { + return "error", errors.New("no valid authentication information provided") + } + return "ok", nil +} + +// GetKernelSpecs retrieves available kernel specifications. +func (c *Client) GetKernelSpecs() (*kernel.KernelSpecs, error) { + return c.kernelClient.GetKernelSpecs() +} + +// ListKernels retrieves all running kernels. +func (c *Client) ListKernels() ([]*kernel.Kernel, error) { + return c.kernelClient.ListKernels() +} + +// GetKernel retrieves information about a specific kernel. +func (c *Client) GetKernel(kernelId string) (*kernel.Kernel, error) { + return c.kernelClient.GetKernel(kernelId) +} + +// StartKernel starts a new kernel. +func (c *Client) StartKernel(name string) (*kernel.Kernel, error) { + return c.kernelClient.StartKernel(name) +} + +// RestartKernel restarts the specified kernel. +func (c *Client) RestartKernel(kernelId string) (bool, error) { + return c.kernelClient.RestartKernel(kernelId) +} + +// InterruptKernel interrupts the specified kernel. +func (c *Client) InterruptKernel(kernelId string) error { + return c.kernelClient.InterruptKernel(kernelId) +} + +// ListSessions retrieves active sessions. +func (c *Client) ListSessions() ([]*session.Session, error) { + return c.sessionClient.ListSessions() +} + +// GetSession retrieves information about a specific session. +func (c *Client) GetSession(sessionId string) (*session.Session, error) { + return c.sessionClient.GetSession(sessionId) +} + +// CreateSession creates a new session. +func (c *Client) CreateSession(name, ipynb, kernel string) (*session.Session, error) { + return c.sessionClient.CreateSession(name, ipynb, kernel) +} + +// DeleteSession deletes the specified session. +func (c *Client) DeleteSession(sessionId string) error { + return c.sessionClient.DeleteSession(sessionId) +} + +// ConnectToKernel establishes a websocket connection to the kernel. +func (c *Client) ConnectToKernel(kernelId string) error { + parsedURL, err := url.Parse(c.BaseURL) + if err != nil { + return fmt.Errorf("invalid base URL: %w", err) + } + + scheme := "ws" + if parsedURL.Scheme == "https" { + scheme = "wss" + } + + wsURL := fmt.Sprintf("%s://%s/api/kernels/%s/channels", scheme, parsedURL.Host, kernelId) + + if c.Auth.Token != "" { + wsURL = fmt.Sprintf("%s?token=%s", wsURL, c.Auth.Token) + } + + return c.executeClient.Connect(wsURL) +} + +// DisconnectFromKernel closes the websocket connection. +func (c *Client) DisconnectFromKernel() { + c.executeClient.Disconnect() +} + +// ExecuteCodeStream streams execution results into resultChan. +func (c *Client) ExecuteCodeStream(kernelId, code string, resultChan chan *execute.ExecutionResult) error { + return c.executeClient.ExecuteCodeStream(code, resultChan) +} + +// ExecuteCodeWithCallback processes execution events via callbacks. +func (c *Client) ExecuteCodeWithCallback(code string, handler execute.CallbackHandler) error { + return c.executeClient.ExecuteCodeWithCallback(code, handler) +} diff --git a/components/execd/pkg/jupyter/debug_integration_test.go b/components/execd/pkg/jupyter/debug_integration_test.go new file mode 100644 index 0000000..1b35385 --- /dev/null +++ b/components/execd/pkg/jupyter/debug_integration_test.go @@ -0,0 +1,166 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jupyter + +import ( + "fmt" + "net/http" + "net/http/httputil" + "testing" +) + +// TestDebugServerIntegration logs real server interactions for debugging. +func TestDebugServerIntegration(t *testing.T) { + jupyterURL := getEnv("JUPYTER_URL", "") + jupyterToken := getEnv("JUPYTER_TOKEN", "") + if jupyterURL == "" || jupyterToken == "" { + t.Skip("JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test") + } + + t.Logf("Connecting to Jupyter server: %s", jupyterURL) + + httpClient := &http.Client{ + Transport: &debugTransport{t: t}, + } + + client := NewClient(jupyterURL, + WithToken(jupyterToken), + WithHTTPClient(httpClient)) + + t.Run("Validate Authentication", func(t *testing.T) { + t.Logf("Calling ValidateAuth...") + status, err := client.ValidateAuth() + if err != nil { + t.Fatalf("Authentication validation failed: %v", err) + } + t.Logf("Authentication validation successful! Status: %s", status) + }) + + t.Run("Get API Information", func(t *testing.T) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api", jupyterURL), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Token %s", jupyterToken)) + + t.Logf("Sending request to /api endpoint...") + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Logf("API request returned non-200 status code: %d %s", resp.StatusCode, resp.Status) + } else { + t.Logf("API request successful, status code: %d %s", resp.StatusCode, resp.Status) + + respDump, err := httputil.DumpResponse(resp, true) + if err != nil { + t.Logf("Unable to dump response: %v", err) + } else { + t.Logf("Response details:\n%s", string(respDump)) + } + } + }) + + t.Run("Test Different Header Combinations", func(t *testing.T) { + headerSets := []map[string]string{ + { + "Authorization": fmt.Sprintf("Token %s", jupyterToken), + }, + { + "Authorization": fmt.Sprintf("Token %s", jupyterToken), + "X-XSRFToken": jupyterToken[:16], // Use first 16 characters of token as XSRF token attempt + }, + { + "Authorization": fmt.Sprintf("token %s", jupyterToken), // lowercase token + }, + { + "Cookie": fmt.Sprintf("_xsrf=%s; jupyter_token=%s", jupyterToken[:16], jupyterToken), + }, + } + + for i, headers := range headerSets { + t.Logf("Testing header combination #%d:", i+1) + for k, v := range headers { + t.Logf(" %s: %s", k, v) + } + + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/kernelspecs", jupyterURL), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + t.Logf("Sending request to /api/kernelspecs endpoint...") + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + t.Logf("Response status code: %d %s", resp.StatusCode, resp.Status) + if resp.StatusCode == http.StatusOK { + t.Logf("Successfully found valid header combination!") + + respDump, err := httputil.DumpResponse(resp, true) + if err != nil { + t.Logf("Unable to dump response: %v", err) + } else { + maxLen := 500 + respStr := string(respDump) + if len(respStr) > maxLen { + t.Logf("Response (truncated):\n%s...", respStr[:maxLen]) + } else { + t.Logf("Response:\n%s", respStr) + } + } + } + } + }) +} + +// debugTransport logs request and response dumps. +type debugTransport struct { + t *testing.T +} + +func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqDump, err := httputil.DumpRequestOut(req, true) + if err != nil { + d.t.Logf("Unable to dump request: %v", err) + } else { + maxLen := 500 + reqStr := string(reqDump) + if len(reqStr) > maxLen { + d.t.Logf("Request (truncated):\n%s...", reqStr[:maxLen]) + } else { + d.t.Logf("Request:\n%s", reqStr) + } + } + + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + return nil, err + } + + d.t.Logf("Response status: %d %s", resp.StatusCode, resp.Status) + + return resp, nil +} diff --git a/components/execd/pkg/jupyter/execute/events.json b/components/execd/pkg/jupyter/execute/events.json new file mode 100644 index 0000000..400ecf2 --- /dev/null +++ b/components/execd/pkg/jupyter/execute/events.json @@ -0,0 +1,232 @@ +[ + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_6", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.206377Z", + "msg_type": "status", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e1df6eb2-f395e4906c9cecd23d97b548_7_2", + "username": "username", + "session": "e1df6eb2-f395e4906c9cecd23d97b548", + "date": "2025-06-06T09:20:51.204953Z", + "msg_type": "kernel_info_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "execution_state": "busy" + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_8", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.207083Z", + "msg_type": "status", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e1df6eb2-f395e4906c9cecd23d97b548_7_1", + "username": "username", + "session": "e1df6eb2-f395e4906c9cecd23d97b548", + "date": "2025-06-06T09:20:51.204866Z", + "msg_type": "kernel_info_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "execution_state": "idle" + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_9", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.207169Z", + "msg_type": "status", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e1df6eb2-f395e4906c9cecd23d97b548_7_2", + "username": "username", + "session": "e1df6eb2-f395e4906c9cecd23d97b548", + "date": "2025-06-06T09:20:51.204953Z", + "msg_type": "kernel_info_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "execution_state": "idle" + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_10", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.248234Z", + "msg_type": "status", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", + "username": "go-client", + "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", + "date": "2025-06-06T17:20:51+08:00", + "msg_type": "execute_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "execution_state": "busy" + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_11", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.248481Z", + "msg_type": "execute_input", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", + "username": "go-client", + "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", + "date": "2025-06-06T17:20:51+08:00", + "msg_type": "execute_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "code": "print('Hello, Jupyter!')\nresult = 2 + 2\nresult", + "execution_count": 1 + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_13", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.253641Z", + "msg_type": "stream", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", + "username": "go-client", + "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", + "date": "2025-06-06T17:20:51+08:00", + "msg_type": "execute_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "name": "stdout", + "text": "Hello, Jupyter!\n" + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_12", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.251743Z", + "msg_type": "execute_result", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", + "username": "go-client", + "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", + "date": "2025-06-06T17:20:51+08:00", + "msg_type": "execute_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "data": { + "text/plain": "4" + }, + "metadata": {}, + "execution_count": 1 + }, + "buffers": [], + "channel": "iopub" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_14", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.255042Z", + "msg_type": "execute_reply", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", + "username": "go-client", + "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", + "date": "2025-06-06T17:20:51+08:00", + "msg_type": "execute_request", + "version": "5.3" + }, + "metadata": { + "dependencies_met": true, + "engine": "d82231bb-94b0-4296-8372-2913351ee2a1", + "started": "2025-06-06T09:20:51.248468Z", + "status": "ok" + }, + "content": { + "status": "ok", + "execution_count": 1, + "user_expressions": {}, + "payload": [] + }, + "buffers": [], + "channel": "shell" + }, + { + "header": { + "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_15", + "username": "username", + "session": "e5e24851-db96ed91126b13f9b603136f", + "date": "2025-06-06T09:20:51.255385Z", + "msg_type": "status", + "version": "5.3" + }, + "parent_header": { + "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", + "username": "go-client", + "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", + "date": "2025-06-06T17:20:51+08:00", + "msg_type": "execute_request", + "version": "5.3" + }, + "metadata": {}, + "content": { + "execution_state": "idle" + }, + "buffers": [], + "channel": "iopub" + } +] diff --git a/components/execd/pkg/jupyter/execute/execute.go b/components/execd/pkg/jupyter/execute/execute.go new file mode 100644 index 0000000..7d0e1bf --- /dev/null +++ b/components/execd/pkg/jupyter/execute/execute.go @@ -0,0 +1,506 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package execute provides functionality for executing Jupyter kernel code via WebSocket +package execute + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/alibaba/opensandbox/internal/safego" + "github.com/google/uuid" + "github.com/gorilla/websocket" + + execdflag "github.com/alibaba/opensandbox/execd/pkg/flag" +) + +// HTTPClient defines the HTTP client interface +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client is the client for code execution +type Client struct { + // Internal HTTP client for sending HTTP requests + httpClient HTTPClient + + // WebSocket connection + conn *websocket.Conn + + // Message handler mappings + handlers map[MessageType]func(*Message) + + // Session ID + session string + + // Message ID counter + msgCounter int + + // Mutex for protecting concurrent access + mu sync.Mutex + + // WebSocket URL for kernel connection + wsURL string +} + +// NewClient creates a new code execution client +func NewClient(baseURL string, httpClient HTTPClient) *Client { + return &Client{ + httpClient: httpClient, + handlers: make(map[MessageType]func(*Message)), + session: uuid.New().String(), + msgCounter: 0, + } +} + +// Connect connects to the WebSocket of the specified kernel +func (c *Client) Connect(wsURL string) error { + c.mu.Lock() + defer c.mu.Unlock() + + // Save WebSocket URL + c.wsURL = wsURL + + // Connect to WebSocket + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if resp != nil && err != nil { + resp.Body.Close() + } + if err != nil { + return fmt.Errorf("failed to connect to kernel: %w", err) + } + c.conn = conn + + // Register default message handlers + c.registerDefaultHandlers() + + // Start message receiving goroutine + safego.Go(func() { c.receiveMessages() }) + + return nil +} + +// Disconnect disconnects the WebSocket connection to the kernel +func (c *Client) Disconnect() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + c.conn.Close() + c.conn = nil + } +} + +// IsConnected checks if connected to the kernel +func (c *Client) IsConnected() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn != nil +} + +type streamExecutionState struct { + startTime time.Time + result *ExecutionResult + executeDone bool + executeMutex sync.Mutex + resultMutex sync.Mutex +} + +func newStreamExecutionState(startTime time.Time) *streamExecutionState { + return &streamExecutionState{ + startTime: startTime, + result: &ExecutionResult{ + Status: "ok", + Stream: make([]*StreamOutput, 0), + ExecutionTime: 0, + }, + } +} + +// ExecuteCodeStream executes code in streaming mode, sending results to the provided channel +func (c *Client) ExecuteCodeStream(code string, resultChan chan *ExecutionResult) error { + if !c.IsConnected() { + return errors.New("not connected to kernel, please call Connect method") + } + + msg, err := c.buildExecuteMessage(code) + if err != nil { + return err + } + + state := newStreamExecutionState(time.Now()) + + // Clear temporary handlers + c.clearTemporaryHandlers() + c.registerExecuteCodeStreamHandlers(state, resultChan) + + if err := c.writeMessage(msg); err != nil { + return fmt.Errorf("failed to send execution request: %w", err) + } + + return nil +} + +func (c *Client) buildExecuteMessage(code string) (*Message, error) { + msgID := c.nextMessageID() + request := &ExecuteRequest{ + Code: code, + Silent: false, + StoreHistory: true, + UserExpressions: make(map[string]string), + AllowStdin: false, + StopOnError: true, + } + + content, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to serialize request: %w", err) + } + + msg := &Message{ + Header: Header{ + MessageID: msgID, + Username: "go-client", + Session: c.session, + Date: time.Now().Format(time.RFC3339), + MessageType: string(MsgExecuteRequest), + Version: "5.3", + }, + ParentHeader: Header{}, + Metadata: make(map[string]interface{}), + Content: content, + Channel: "shell", + } + + return msg, nil +} + +func (c *Client) registerExecuteCodeStreamHandlers(state *streamExecutionState, resultChan chan *ExecutionResult) { + c.registerHandler(MsgExecuteReply, func(msg *Message) { + c.handleExecuteReply(msg, state) + }) + c.registerHandler(MsgExecuteResult, func(msg *Message) { + c.handleExecuteResult(msg, state, resultChan) + }) + c.registerHandler(MsgStream, func(msg *Message) { + c.handleStreamOutput(msg, state, resultChan) + }) + c.registerHandler(MsgError, func(msg *Message) { + c.handleExecutionError(msg, state, resultChan) + }) + c.registerHandler(MsgStatus, func(msg *Message) { + c.handleExecutionStatus(msg, state, resultChan) + }) +} + +func (c *Client) handleExecuteReply(msg *Message, state *streamExecutionState) { + var execReply ExecuteReply + if err := json.Unmarshal(msg.Content, &execReply); err != nil { + return + } + + state.resultMutex.Lock() + defer state.resultMutex.Unlock() + state.result.ExecutionCount = execReply.ExecutionCount + if execReply.EName != "" { + state.result.Error = &execReply.ErrorOutput + } +} + +func (c *Client) handleExecuteResult(msg *Message, state *streamExecutionState, resultChan chan *ExecutionResult) { + var execResult ExecuteResult + if err := json.Unmarshal(msg.Content, &execResult); err != nil { + return + } + + state.resultMutex.Lock() + defer state.resultMutex.Unlock() + state.result.ExecutionCount = execResult.ExecutionCount + + notify := &ExecutionResult{ + ExecutionCount: execResult.ExecutionCount, + ExecutionData: execResult.Data, + } + resultChan <- notify +} + +func (c *Client) handleStreamOutput(msg *Message, state *streamExecutionState, resultChan chan *ExecutionResult) { + var stream StreamOutput + if err := json.Unmarshal(msg.Content, &stream); err != nil { + return + } + + state.resultMutex.Lock() + defer state.resultMutex.Unlock() + state.result.Stream = append(state.result.Stream, &stream) + notify := &ExecutionResult{ + Stream: []*StreamOutput{&stream}, + } + resultChan <- notify +} + +func (c *Client) handleExecutionError(msg *Message, state *streamExecutionState, resultChan chan *ExecutionResult) { + var errOutput ErrorOutput + if err := json.Unmarshal(msg.Content, &errOutput); err != nil { + return + } + + state.resultMutex.Lock() + defer state.resultMutex.Unlock() + state.result.Status = "error" + state.result.Error = &errOutput + notify := &ExecutionResult{ + Error: &errOutput, + Status: "error", + } + resultChan <- notify +} + +func (c *Client) handleExecutionStatus(msg *Message, state *streamExecutionState, resultChan chan *ExecutionResult) { + var status StatusUpdate + if err := json.Unmarshal(msg.Content, &status); err != nil { + return + } + if status.ExecutionState != StateIdle { + return + } + + state.executeMutex.Lock() + defer state.executeMutex.Unlock() + if state.executeDone { + return + } + state.executeDone = true + safego.Go(func() { c.finalizeExecution(state, resultChan) }) +} + +func (c *Client) finalizeExecution(state *streamExecutionState, resultChan chan *ExecutionResult) { + state.resultMutex.Lock() + state.result.ExecutionTime = time.Since(state.startTime) + notify := &ExecutionResult{ + ExecutionTime: state.result.ExecutionTime, + } + resultChan <- notify + state.resultMutex.Unlock() + + pollInterval := execdflag.JupyterIdlePollInterval + if pollInterval <= 0 { + pollInterval = 100 * time.Millisecond + } + + for { + state.resultMutex.Lock() + done := state.result.ExecutionCount > 0 || state.result.Error != nil + state.resultMutex.Unlock() + if done { + break + } + time.Sleep(pollInterval) + } + + close(resultChan) +} + +func (c *Client) writeMessage(msg *Message) error { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn.WriteJSON(msg) +} + +// ExecuteCodeWithCallback executes code using callback functions +func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) error { + if !c.IsConnected() { + return errors.New("not connected to kernel, please call Connect method") + } + + // prepare execution request + msgID := c.nextMessageID() + request := &ExecuteRequest{ + Code: code, + Silent: false, + StoreHistory: true, + UserExpressions: make(map[string]string), + AllowStdin: false, + StopOnError: true, + } + + // serialize request content + content, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to serialize request: %w", err) + } + + // create message + msg := &Message{ + Header: Header{ + MessageID: msgID, + Username: "go-client", + Session: c.session, + Date: time.Now().Format(time.RFC3339), + MessageType: string(MsgExecuteRequest), + Version: "5.3", + }, + ParentHeader: Header{}, + Metadata: make(map[string]interface{}), + Content: content, + Channel: "shell", + } + + // register execution result handler + if handler.OnExecuteResult != nil { + c.registerHandler(MsgExecuteResult, func(msg *Message) { + var execResult ExecuteResult + if err := json.Unmarshal(msg.Content, &execResult); err != nil { + return + } + + // calls callback functions + handler.OnExecuteResult(&execResult) + }) + } + + // Register stream output handler + if handler.OnStream != nil { + c.registerHandler(MsgStream, func(msg *Message) { + var stream StreamOutput + if err := json.Unmarshal(msg.Content, &stream); err != nil { + return + } + + // calls callback functions + handler.OnStream(&stream) + }) + } + + // Register display data handler + if handler.OnDisplayData != nil { + c.registerHandler(MsgDisplayData, func(msg *Message) { + var display DisplayData + if err := json.Unmarshal(msg.Content, &display); err != nil { + return + } + + // calls callback functions + handler.OnDisplayData(&display) + }) + } + + // register error handler + if handler.OnError != nil { + c.registerHandler(MsgError, func(msg *Message) { + var errOutput ErrorOutput + if err := json.Unmarshal(msg.Content, &errOutput); err != nil { + return + } + + // calls callback functions + handler.OnError(&errOutput) + }) + } + + // register status handler + if handler.OnStatus != nil { + c.registerHandler(MsgStatus, func(msg *Message) { + var status StatusUpdate + if err := json.Unmarshal(msg.Content, &status); err != nil { + return + } + + // calls callback functions + handler.OnStatus(&status) + }) + } + + // send execution request + c.mu.Lock() + err = c.conn.WriteJSON(msg) + c.mu.Unlock() + if err != nil { + return fmt.Errorf("failed to send execution request: %w", err) + } + + return nil +} + +// Register default message handlers +func (c *Client) registerDefaultHandlers() { + // default message handlers can be registered here +} + +// Register temporary message handler +func (c *Client) registerHandler(msgType MessageType, handler func(*Message)) { + c.mu.Lock() + defer c.mu.Unlock() + c.handlers[msgType] = handler +} + +// Clear temporary message handlers +func (c *Client) clearTemporaryHandlers() { + c.mu.Lock() + defer c.mu.Unlock() + c.handlers = make(map[MessageType]func(*Message)) + c.registerDefaultHandlers() +} + +// Receive WebSocket messages +func (c *Client) receiveMessages() { + for { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + break + } + + // Receive message + var msg Message + err := conn.ReadJSON(&msg) + if err != nil { + // connection may already be closed + break + } + + // Process message + c.handleMessage(&msg) + } +} + +// Handle received messages +func (c *Client) handleMessage(msg *Message) { + // Extract message type + msgType := MessageType(msg.Header.MessageType) + + // call the corresponding handler + c.mu.Lock() + handler, ok := c.handlers[msgType] + c.mu.Unlock() + + if ok && handler != nil { + handler(msg) + } +} + +// generate next messageID +func (c *Client) nextMessageID() string { + c.mu.Lock() + defer c.mu.Unlock() + c.msgCounter++ + return fmt.Sprintf("%s-%d", c.session, c.msgCounter) +} diff --git a/components/execd/pkg/jupyter/execute/execute_test.go b/components/execd/pkg/jupyter/execute/execute_test.go new file mode 100644 index 0000000..7ed18db --- /dev/null +++ b/components/execd/pkg/jupyter/execute/execute_test.go @@ -0,0 +1,300 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package execute + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + execdflag "github.com/alibaba/opensandbox/execd/pkg/flag" +) + +// Create WebSocket test server +func createTestServer(t *testing.T, handleFunc func(conn *websocket.Conn)) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Validate request path + if !strings.HasPrefix(r.URL.Path, "/api/kernels/") { + t.Errorf("expected path to start with '/api/kernels/', got '%s'", r.URL.Path) + } + if !strings.HasSuffix(r.URL.Path, "/channels") { + t.Errorf("expected path to end with '/channels', got '%s'", r.URL.Path) + } + + // Upgrade HTTP connection to WebSocket + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("failed to upgrade to WebSocket: %v", err) + } + defer conn.Close() + + // Handle WebSocket connection + handleFunc(conn) + })) + + return server +} + +// Test streaming code execution +func TestExecuteCodeStream(t *testing.T) { + // Spin up mock WebSocket server + server := createTestServer(t, func(conn *websocket.Conn) { + // Read execution request + var executeRequest Message + err := conn.ReadJSON(&executeRequest) + if err != nil { + t.Fatalf("failed to read execution request: %v", err) + } + + // Send multiple stream messages + for i := 0; i < 3; i++ { + streamContent, _ := json.Marshal(StreamOutput{ + Name: StreamStdout, + Text: "Line " + string(rune('0'+i)) + "\n", + }) + + streamMsg := Message{ + Header: Header{ + MessageID: "stream-msg-id-" + string(rune('0'+i)), + Session: executeRequest.Header.Session, + MessageType: string(MsgStream), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(streamContent), + } + conn.WriteJSON(streamMsg) + time.Sleep(100 * time.Millisecond) + } + + // Send execution result + resultContent, _ := json.Marshal(ExecuteResult{ + ExecutionCount: 1, + Data: map[string]interface{}{ + "text/plain": "Completed", + }, + Metadata: map[string]interface{}{}, + }) + + executeResultMsg := Message{ + Header: Header{ + MessageID: "result-msg-id", + Session: executeRequest.Header.Session, + MessageType: string(MsgExecuteResult), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(resultContent), + } + conn.WriteJSON(executeResultMsg) + + // Send status message + statusContent, _ := json.Marshal(StatusUpdate{ + ExecutionState: StateIdle, + }) + + statusMsg := Message{ + Header: Header{ + MessageID: "status-msg-id", + Session: executeRequest.Header.Session, + MessageType: string(MsgStatus), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(statusContent), + } + conn.WriteJSON(statusMsg) + }) + defer server.Close() + + // Convert HTTP URL to WebSocket URL + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/kernels/test-kernel-id/channels" + + // Create executor client + executor := NewExecutor(wsURL, nil) + + // Connect to WebSocket + err := executor.Connect() + if err != nil { + t.Fatalf("failed to connect to WebSocket: %v", err) + } + defer executor.Disconnect() + + // Execute code in streaming mode + resultChan := make(chan *ExecutionResult, 10) + err = executor.ExecuteCodeStream("for i in range(3):\n print(f'Line {i}')", resultChan) + if err != nil { + t.Fatalf("failed to start streaming execution: %v", err) + } + + // Receive and verify stream results + resultCount := 0 + for result := range resultChan { + if result == nil { + break + } + resultCount++ + } + + // Should receive at least 4 results (3 stream outputs + 1 final result) + if resultCount < 4 { + t.Errorf("expected at least 4 results, got %d", resultCount) + } +} + +func TestExecuteCodeStreamWaitsForLateExecuteResultUsingConfiguredPollInterval(t *testing.T) { + previousPollInterval := execdflag.JupyterIdlePollInterval + execdflag.JupyterIdlePollInterval = time.Millisecond + t.Cleanup(func() { + execdflag.JupyterIdlePollInterval = previousPollInterval + }) + + server := createTestServer(t, func(conn *websocket.Conn) { + var executeRequest Message + err := conn.ReadJSON(&executeRequest) + if err != nil { + t.Fatalf("failed to read execution request: %v", err) + } + + statusContent, _ := json.Marshal(StatusUpdate{ExecutionState: StateIdle}) + statusMsg := Message{ + Header: Header{ + MessageID: "status-msg-id", + Session: executeRequest.Header.Session, + MessageType: string(MsgStatus), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(statusContent), + } + require.NoError(t, conn.WriteJSON(statusMsg)) + + time.Sleep(15 * time.Millisecond) + + resultContent, _ := json.Marshal(ExecuteResult{ + ExecutionCount: 1, + Data: map[string]interface{}{ + "text/plain": "Completed late", + }, + Metadata: map[string]interface{}{}, + }) + executeResultMsg := Message{ + Header: Header{ + MessageID: "result-msg-id", + Session: executeRequest.Header.Session, + MessageType: string(MsgExecuteResult), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(resultContent), + } + require.NoError(t, conn.WriteJSON(executeResultMsg)) + }) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/kernels/test-kernel-id/channels" + executor := NewExecutor(wsURL, nil) + require.NoError(t, executor.Connect()) + defer executor.Disconnect() + + resultChan := make(chan *ExecutionResult, 10) + require.NoError(t, executor.ExecuteCodeStream("print('late result')", resultChan)) + + start := time.Now() + var gotLateResult bool + for result := range resultChan { + if result != nil && result.ExecutionCount == 1 { + gotLateResult = true + } + } + elapsed := time.Since(start) + + require.True(t, gotLateResult, "expected late execute_result to be delivered before stream close") + require.Less(t, elapsed, 100*time.Millisecond, "expected stream to close promptly after late execute_result") +} + +func TestExecuteCodeStreamFallsBackWhenPollIntervalIsNonPositive(t *testing.T) { + previousPollInterval := execdflag.JupyterIdlePollInterval + execdflag.JupyterIdlePollInterval = 0 + t.Cleanup(func() { + execdflag.JupyterIdlePollInterval = previousPollInterval + }) + + server := createTestServer(t, func(conn *websocket.Conn) { + var executeRequest Message + err := conn.ReadJSON(&executeRequest) + if err != nil { + t.Fatalf("failed to read execution request: %v", err) + } + + statusContent, _ := json.Marshal(StatusUpdate{ExecutionState: StateIdle}) + statusMsg := Message{ + Header: Header{ + MessageID: "status-msg-id", + Session: executeRequest.Header.Session, + MessageType: string(MsgStatus), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(statusContent), + } + require.NoError(t, conn.WriteJSON(statusMsg)) + + time.Sleep(15 * time.Millisecond) + + resultContent, _ := json.Marshal(ExecuteResult{ + ExecutionCount: 1, + Data: map[string]interface{}{ + "text/plain": "Completed with fallback", + }, + Metadata: map[string]interface{}{}, + }) + executeResultMsg := Message{ + Header: Header{ + MessageID: "result-msg-id", + Session: executeRequest.Header.Session, + MessageType: string(MsgExecuteResult), + }, + ParentHeader: executeRequest.Header, + Content: json.RawMessage(resultContent), + } + require.NoError(t, conn.WriteJSON(executeResultMsg)) + }) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/kernels/test-kernel-id/channels" + executor := NewExecutor(wsURL, nil) + require.NoError(t, executor.Connect()) + defer executor.Disconnect() + + resultChan := make(chan *ExecutionResult, 10) + require.NoError(t, executor.ExecuteCodeStream("print('fallback')", resultChan)) + + start := time.Now() + var gotLateResult bool + for result := range resultChan { + if result != nil && result.ExecutionCount == 1 { + gotLateResult = true + } + } + elapsed := time.Since(start) + + require.True(t, gotLateResult, "expected late execute_result to be delivered before stream close") + require.GreaterOrEqual(t, elapsed, 90*time.Millisecond, "expected non-positive poll interval to fall back to runtime default (100ms)") + require.Less(t, elapsed, 300*time.Millisecond, "expected fallback poll interval to still close stream promptly") +} diff --git a/components/execd/pkg/jupyter/execute/executor.go b/components/execd/pkg/jupyter/execute/executor.go new file mode 100644 index 0000000..703cb26 --- /dev/null +++ b/components/execd/pkg/jupyter/execute/executor.go @@ -0,0 +1,52 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package execute + +// Executor is the interface for code execution +type Executor struct { + // Internal client + client *Client + // WebSocket URL + wsURL string +} + +// NewExecutor creates a new code executor +func NewExecutor(wsURL string, httpClient HTTPClient) *Executor { + client := NewClient("", httpClient) + return &Executor{ + client: client, + wsURL: wsURL, + } +} + +// Connect connects to the kernel +func (e *Executor) Connect() error { + return e.client.Connect(e.wsURL) +} + +// Disconnect disconnects from the kernel +func (e *Executor) Disconnect() { + e.client.Disconnect() +} + +// ExecuteCodeStream executes code in streaming mode, sending results to the provided channel +func (e *Executor) ExecuteCodeStream(code string, resultChan chan *ExecutionResult) error { + return e.client.ExecuteCodeStream(code, resultChan) +} + +// ExecuteCodeWithCallback executes code using callback functions +func (e *Executor) ExecuteCodeWithCallback(code string, handler CallbackHandler) error { + return e.client.ExecuteCodeWithCallback(code, handler) +} diff --git a/components/execd/pkg/jupyter/execute/types.go b/components/execd/pkg/jupyter/execute/types.go new file mode 100644 index 0000000..fd6ef2a --- /dev/null +++ b/components/execd/pkg/jupyter/execute/types.go @@ -0,0 +1,264 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package execute provides functionality for executing Jupyter kernel code via WebSocket +package execute + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// MessageType represents Jupyter message types +type MessageType string + +const ( + // MsgExecuteRequest requests code execution + MsgExecuteRequest MessageType = "execute_request" + + // MsgExecuteInput represents the input code + MsgExecuteInput MessageType = "execute_input" + + // MsgExecuteResult represents execution results + MsgExecuteResult MessageType = "execute_result" + + // MsgDisplayData represents data to be displayed + MsgDisplayData MessageType = "display_data" + + // MsgStream represents stream output (stdout/stderr) + MsgStream MessageType = "stream" + + // MsgError represents errors during execution + MsgError MessageType = "error" + + // MsgStatus represents kernel status updates + MsgStatus MessageType = "status" + + // MsgClearOutput represents clearing output + MsgClearOutput MessageType = "clear_output" + + // MsgComm represents communication messages + MsgComm MessageType = "comm" + + // MsgCommOpen represents opening communication + MsgCommOpen MessageType = "comm_open" + + // MsgCommClose represents closing communication + MsgCommClose MessageType = "comm_close" + + // MsgCommMsg representscommunication message content + MsgCommMsg MessageType = "comm_msg" + + // MsgKernelInfo represents kernel information request + MsgKernelInfo MessageType = "kernel_info_request" + + // MsgKernelInfoReply represents kernel information response + MsgKernelInfoReply MessageType = "kernel_info_reply" + + MsgExecuteReply MessageType = "execute_reply" +) + +// StreamType representsoutput stream type +type StreamType string + +const ( + // StreamStdout represents standard output stream + StreamStdout StreamType = "stdout" + + // StreamStderr representsstandard error stream + StreamStderr StreamType = "stderr" +) + +// ExecutionState represents kernel execution state +type ExecutionState string + +const ( + // StateIdle representskernel is idle + StateIdle ExecutionState = "idle" + + // StateBusy representskernel is busy + StateBusy ExecutionState = "busy" + + // StateStarting representskernel is starting + StateStarting ExecutionState = "starting" +) + +// Header defines Jupyter message header +type Header struct { + // MessageID is the unique identifier of the message + MessageID string `json:"msg_id"` + + // Username is the username sending the message + Username string `json:"username"` + + // Session is the session identifier + Session string `json:"session"` + + // Date is the timestamp when the message was sent + Date string `json:"date"` + + // MessageType is the type of the message + MessageType string `json:"msg_type"` + + // Version is the version of the message protocol + Version string `json:"version"` +} + +// Message defines the basic structure of Jupyter messages +type Message struct { + // Header is the message header + Header Header `json:"header"` + + // ParentHeader is the parent message header, used to track requests and responses + ParentHeader Header `json:"parent_header"` + + // Metadata is the metadata related to the message + Metadata map[string]interface{} `json:"metadata"` + + // Content is the actual content of the message + Content json.RawMessage `json:"content"` + + // Buffers is the binary buffer + Buffers [][]byte `json:"buffers"` + + // Channel is the channel of the message + Channel string `json:"channel"` +} + +// ExecuteRequest defines the request content for code execution +type ExecuteRequest struct { + // Code is the code to execute + Code string `json:"code"` + + // Silent represents whether to execute in silent mode + Silent bool `json:"silent"` + + // StoreHistory represents whether to store execution history + StoreHistory bool `json:"store_history"` + + // UserExpressions contains expressions to evaluate in the execution context + UserExpressions map[string]string `json:"user_expressions"` + + // AllowStdin represents whether to allow reading from standard input + AllowStdin bool `json:"allow_stdin"` + + // StopOnError represents whether to stop execution when an error is encountered + StopOnError bool `json:"stop_on_error"` +} + +// StreamOutput represents stream output content +type StreamOutput struct { + // Name is the stream name (stdout or stderr) + Name StreamType `json:"name"` + + // Text is the text content of the stream + Text string `json:"text"` +} + +// ExecuteResult represents the result of code execution +type ExecuteResult struct { + // ExecutionCount is the execution counter value + ExecutionCount int `json:"execution_count"` + + // Data contains result data in different formats + Data map[string]interface{} `json:"data"` + + // Metadata is the metadata related to the result + Metadata map[string]interface{} `json:"metadata"` +} + +type ExecuteReply struct { + // ExecutionCount is the execution counter value + ExecutionCount int `json:"execution_count"` + + Status string `json:"status"` + + ErrorOutput `json:",inline"` +} + +// DisplayData representsdata to display +type DisplayData struct { + // Data contains display data in different formats + Data map[string]interface{} `json:"data"` + + // Metadata is the metadata related to display data + Metadata map[string]interface{} `json:"metadata"` +} + +// ErrorOutput representserrors during execution +type ErrorOutput struct { + // EName is the name of the error + EName string `json:"ename"` + + // EValue is the value of the error + EValue string `json:"evalue"` + + // Traceback is the traceback of the error + Traceback []string `json:"traceback"` +} + +func (e *ErrorOutput) String() string { + return fmt.Sprintf(` +Error: %s +Value: %s +Traceback: %s +`, e.EName, e.EValue, strings.Join(e.Traceback, "\n")) +} + +// StatusUpdate represents kernel status update +type StatusUpdate struct { + // ExecutionState is the execution state of the kernel + ExecutionState ExecutionState `json:"execution_state"` +} + +// ExecutionResult represents the complete result of code execution +type ExecutionResult struct { + // Status represents the status of execution + Status string `json:"status"` + + // ExecutionCount is the execution counter value + ExecutionCount int `json:"execution_count"` + + // Stream contains all stream output + Stream []*StreamOutput `json:"stream"` + + // Error contains errors during execution (if any) + Error *ErrorOutput `json:"error"` + + // ExecutionTime is the total time of code execution + ExecutionTime time.Duration `json:"execution_time"` + + // ExecutionData + ExecutionData map[string]interface{} `json:"execution_data"` +} + +// CallbackHandler defines callback functions for handling different types of messages +type CallbackHandler struct { + // OnExecuteResult handles execution result messages + OnExecuteResult func(*ExecuteResult) + + // OnStream handles stream output messages + OnStream func(...*StreamOutput) + + // OnDisplayData handles display data messages + OnDisplayData func(*DisplayData) + + // OnError handles error messages + OnError func(*ErrorOutput) + + // OnStatus handles status update messages + OnStatus func(*StatusUpdate) +} diff --git a/components/execd/pkg/jupyter/execute/zz_generated.deepcopy.go b/components/execd/pkg/jupyter/execute/zz_generated.deepcopy.go new file mode 100644 index 0000000..ad75520 --- /dev/null +++ b/components/execd/pkg/jupyter/execute/zz_generated.deepcopy.go @@ -0,0 +1,87 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package execute + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ErrorOutput) DeepCopyInto(out *ErrorOutput) { + *out = *in + if in.Traceback != nil { + in, out := &in.Traceback, &out.Traceback + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ErrorOutput. +func (in *ErrorOutput) DeepCopy() *ErrorOutput { + if in == nil { + return nil + } + out := new(ErrorOutput) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExecutionResult) DeepCopyInto(out *ExecutionResult) { + *out = *in + if in.Stream != nil { + in, out := &in.Stream, &out.Stream + *out = make([]*StreamOutput, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(StreamOutput) + **out = **in + } + } + } + if in.Error != nil { + in, out := &in.Error, &out.Error + *out = new(ErrorOutput) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionResult. +func (in *ExecutionResult) DeepCopy() *ExecutionResult { + if in == nil { + return nil + } + out := new(ExecutionResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StreamOutput) DeepCopyInto(out *StreamOutput) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StreamOutput. +func (in *StreamOutput) DeepCopy() *StreamOutput { + if in == nil { + return nil + } + out := new(StreamOutput) + in.DeepCopyInto(out) + return out +} diff --git a/components/execd/pkg/jupyter/integration_test.go b/components/execd/pkg/jupyter/integration_test.go new file mode 100644 index 0000000..2d371da --- /dev/null +++ b/components/execd/pkg/jupyter/integration_test.go @@ -0,0 +1,328 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jupyter + +import ( + "encoding/json" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" +) + +// Test integration flow: authentication -> get kernel specs -> create session -> execute code -> close session +func TestIntegrationFlow(t *testing.T) { + // Create mock HTTP server + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle authentication validation request + if r.URL.Path == "/api/status" { + // Check authentication token + auth := r.Header.Get("Authorization") + if auth != "token test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Return status information + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status": "ok"}`)) + return + } + + // Handle kernel specs request + if r.URL.Path == "/api/kernelspecs" { + // Return kernel specs + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "default": "python3", + "kernelspecs": { + "python3": { + "name": "python3", + "display_name": "Python 3", + "language": "python" + } + } + }`)) + return + } + + // Handle session-related requests + if r.URL.Path == "/api/sessions" { + if r.Method == http.MethodGet { + // List sessions + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{ + "id": "test-session-id", + "path": "/path/to/notebook.ipynb", + "name": "Test Session", + "type": "notebook", + "kernel": { + "id": "test-kernel-id", + "name": "python3" + } + }]`)) + return + } else if r.Method == http.MethodPost { + // Create session + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{ + "id": "test-session-id", + "path": "/path/to/notebook.ipynb", + "name": "Test Session", + "type": "notebook", + "kernel": { + "id": "test-kernel-id", + "name": "python3" + } + }`)) + return + } + } + + // Handle specific session requests + if strings.HasPrefix(r.URL.Path, "/api/sessions/test-session-id") { + if r.Method == http.MethodDelete { + // Delete session + w.WriteHeader(http.StatusNoContent) + return + } else if r.Method == http.MethodPatch { + // Modify session + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "id": "test-session-id", + "path": "/path/to/updated-notebook.ipynb", + "name": "Updated Test Session", + "type": "notebook", + "kernel": { + "id": "test-kernel-id", + "name": "python3" + } + }`)) + return + } else if r.Method == http.MethodGet { + // Get session + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "id": "test-session-id", + "path": "/path/to/notebook.ipynb", + "name": "Test Session", + "type": "notebook", + "kernel": { + "id": "test-kernel-id", + "name": "python3" + } + }`)) + return + } + } + + // Handle kernel requests + if r.URL.Path == "/api/kernels" { + if r.Method == http.MethodGet { + // List kernels + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{ + "id": "test-kernel-id", + "name": "python3", + "execution_state": "idle" + }]`)) + return + } + } + + // Handle specific kernel requests + if strings.HasPrefix(r.URL.Path, "/api/kernels/test-kernel-id") { + if r.Method == http.MethodGet { + // Get kernel + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "id": "test-kernel-id", + "name": "python3", + "execution_state": "idle" + }`)) + return + } else if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/restart") { + // Restart kernel + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "id": "test-kernel-id", + "name": "python3", + "restarted": true + }`)) + return + } + } + + // If it's a WebSocket connection request, upgrade to WebSocket + if strings.HasSuffix(r.URL.Path, "/channels") { + // Return 404, as WebSocket connections will be handled by a dedicated WebSocket server + w.WriteHeader(http.StatusNotFound) + return + } + + // For other requests, return 404 + w.WriteHeader(http.StatusNotFound) + })) + defer httpServer.Close() + + // Create mock WebSocket server for code execution + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/channels") { + w.WriteHeader(http.StatusNotFound) + return + } + + // Upgrade HTTP connection to WebSocket + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("Failed to upgrade connection to WebSocket: %v", err) + } + defer conn.Close() + + // Continuously handle WebSocket messages + for { + // Read request message + var msg execute.Message + err := conn.ReadJSON(&msg) + if err != nil { + break + } + + // If it's an execute request, send mock response + if msg.Header.MessageType == string(execute.MsgExecuteRequest) { + // Send stream output + streamContent, _ := json.Marshal(execute.StreamOutput{ + Name: execute.StreamStdout, + Text: "Hello from test WebSocket!\n", + }) + + streamMsg := execute.Message{ + Header: execute.Header{ + MessageID: "stream-msg-id", + Session: msg.Header.Session, + MessageType: string(execute.MsgStream), + }, + ParentHeader: msg.Header, + Content: json.RawMessage(streamContent), + } + conn.WriteJSON(streamMsg) + + // Send execution result + resultContent, _ := json.Marshal(execute.ExecuteResult{ + ExecutionCount: 1, + Data: map[string]interface{}{ + "text/plain": "Integration test result", + }, + Metadata: map[string]interface{}{}, + }) + + executeResultMsg := execute.Message{ + Header: execute.Header{ + MessageID: "result-msg-id", + Session: msg.Header.Session, + MessageType: string(execute.MsgExecuteResult), + }, + ParentHeader: msg.Header, + Content: json.RawMessage(resultContent), + } + conn.WriteJSON(executeResultMsg) + + // Send status message + statusContent, _ := json.Marshal(execute.StatusUpdate{ + ExecutionState: execute.StateIdle, + }) + + statusMsg := execute.Message{ + Header: execute.Header{ + MessageID: "status-msg-id", + Session: msg.Header.Session, + MessageType: string(execute.MsgStatus), + }, + ParentHeader: msg.Header, + Content: json.RawMessage(statusContent), + } + conn.WriteJSON(statusMsg) + } + } + })) + defer wsServer.Close() + + // Create Jupyter client + client := NewClient(httpServer.URL) + client.SetToken("test-token") + + // Test 1: Validate authentication + status, err := client.ValidateAuth() + if err != nil { + t.Fatalf("Authentication validation failed: %v", err) + } + if status != "ok" { + t.Errorf("Authentication status incorrect, expected 'ok', got '%s'", status) + } + + // Test 2: Get kernel specs + specs, err := client.GetKernelSpecs() + if err != nil { + t.Fatalf("Failed to get kernel specs: %v", err) + } + if specs.Default != "python3" { + t.Errorf("Default kernel incorrect, expected 'python3', got '%s'", specs.Default) + } + if len(specs.Kernelspecs) != 1 { + t.Errorf("Kernel count incorrect, expected 1, got %d", len(specs.Kernelspecs)) + } + + // Test 3: Create session + session, err := client.CreateSession("Test Session", "/path/to/notebook.ipynb", "python3") + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + if session.ID != "test-session-id" { + t.Errorf("Session ID incorrect, expected 'test-session-id', got '%s'", session.ID) + } + if session.Kernel.ID != "test-kernel-id" { + t.Errorf("Kernel ID incorrect, expected 'test-kernel-id', got '%s'", session.Kernel.ID) + } + + // Modify WebSocket URL to point to WebSocket test server + wsURL := "ws" + strings.TrimPrefix(wsServer.URL, "http") + "/api/kernels/test-kernel-id/channels" + + // Test 4: Connect to kernel and execute code + executor := execute.NewExecutor(wsURL, nil) + err = executor.Connect() + if err != nil { + t.Fatalf("Failed to connect to kernel: %v", err) + } + defer executor.Disconnect() + + // Execute code + err = executor.ExecuteCodeWithCallback("print('Hello from integration test!')", execute.CallbackHandler{}) + if err != nil { + t.Fatalf("Failed to execute code: %v", err) + } + + // Test 5: Delete session + err = client.DeleteSession(session.ID) + if err != nil { + t.Fatalf("Failed to delete session: %v", err) + } +} diff --git a/components/execd/pkg/jupyter/kernel/kernel.go b/components/execd/pkg/jupyter/kernel/kernel.go new file mode 100644 index 0000000..3badcfd --- /dev/null +++ b/components/execd/pkg/jupyter/kernel/kernel.go @@ -0,0 +1,253 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package kernel provides functionality for managing Jupyter kernels +package kernel + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Client is the client for kernel management +type Client struct { + // baseURL is the base URL of the Jupyter server + baseURL string + + // httpClient is the client for sending HTTP requests, with authentication support + httpClient *http.Client +} + +// NewClient creates a new kernel management client +func NewClient(baseURL string, httpClient *http.Client) *Client { + return &Client{ + baseURL: baseURL, + httpClient: httpClient, + } +} + +// GetKernelSpecs retrieves the list of available kernel specifications +func (c *Client) GetKernelSpecs() (*KernelSpecs, error) { + // Build request URL + url := fmt.Sprintf("%s/api/kernelspecs", c.baseURL) + + // Send GET request + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var specs KernelSpecs + if err := json.Unmarshal(body, &specs); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &specs, nil +} + +// ListKernels retrieves the list of all running kernels +func (c *Client) ListKernels() ([]*Kernel, error) { + // Build request URL + url := fmt.Sprintf("%s/api/kernels", c.baseURL) + + // Send GET request + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var kernels []*Kernel + if err := json.Unmarshal(body, &kernels); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return kernels, nil +} + +// GetKernel retrieves information about a specific kernel +func (c *Client) GetKernel(kernelId string) (*Kernel, error) { + // Build request URL + url := fmt.Sprintf("%s/api/kernels/%s", c.baseURL, kernelId) + + // Send GET request + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var kernel Kernel + if err := json.Unmarshal(body, &kernel); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &kernel, nil +} + +// StartKernel starts a new kernel +func (c *Client) StartKernel(name string) (*Kernel, error) { + // Build request URL + url := fmt.Sprintf("%s/api/kernels", c.baseURL) + + // Build request body + reqBody := &KernelStartRequest{ + Name: name, + } + + // Serialize request body to JSON + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to serialize request: %w", err) + } + + // Create POST request + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var kernel Kernel + if err := json.Unmarshal(body, &kernel); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &kernel, nil +} + +// RestartKernel restarts the specified kernel +func (c *Client) RestartKernel(kernelId string) (bool, error) { + // Build request URL + url := fmt.Sprintf("%s/api/kernels/%s/restart", c.baseURL, kernelId) + + // Create POST request + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return false, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var response KernelRestartResponse + if err := json.Unmarshal(body, &response); err != nil { + return false, fmt.Errorf("failed to parse response: %w", err) + } + + return response.Restarted, nil +} + +// InterruptKernel interrupts the specified kernel +func (c *Client) InterruptKernel(kernelId string) error { + // Build request URL + url := fmt.Sprintf("%s/api/kernels/%s/interrupt", c.baseURL, kernelId) + + // Create POST request + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + return nil +} diff --git a/components/execd/pkg/jupyter/kernel/kernelspecs.json b/components/execd/pkg/jupyter/kernel/kernelspecs.json new file mode 100644 index 0000000..4e61d8a --- /dev/null +++ b/components/execd/pkg/jupyter/kernel/kernelspecs.json @@ -0,0 +1,23 @@ +{ + "default" : "python3", + "kernelspecs" : { + "python3" : { + "name" : "python3", + "spec" : { + "argv" : [ "/opt/conda/bin/python", "-m", "ipykernel_launcher", "-f", "{connection_file}" ], + "env" : { }, + "display_name" : "Python 3 (ipykernel)", + "language" : "python", + "interrupt_mode" : "signal", + "metadata" : { + "debugger" : true + } + }, + "resources" : { + "logo-svg" : "/kernelspecs/python3/logo-svg.svg", + "logo-64x64" : "/kernelspecs/python3/logo-64x64.png", + "logo-32x32" : "/kernelspecs/python3/logo-32x32.png" + } + } + } +} diff --git a/components/execd/pkg/jupyter/kernel/types.go b/components/execd/pkg/jupyter/kernel/types.go new file mode 100644 index 0000000..f21e476 --- /dev/null +++ b/components/execd/pkg/jupyter/kernel/types.go @@ -0,0 +1,121 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package kernel provides functionality for managing Jupyter kernels +package kernel + +import ( + "time" +) + +// KernelSpecs contains available kernel specification information +type KernelSpecs struct { + // Default is the name of the default kernel + Default string `json:"default"` + + // Kernelspecs is a mapping from kernel names to kernel specifications + Kernelspecs map[string]*KernelSpecInfo `json:"kernelspecs"` +} + +// KernelSpecInfo contains detailed kernel specification information +type KernelSpecInfo struct { + // Name is the name of the kernel + Name string `json:"name"` + + Spec KernelSpecDetail `json:"spec"` + + // Resources contains resource paths related to the kernel + Resources map[string]string `json:"resources,omitempty"` +} + +type KernelSpecDetail struct { + Argv []string `json:"argv,omitempty"` + + // DisplayName is the display name of the kernel + DisplayName string `json:"display_name"` + + // Language is the programming language used by the kernel + Language string `json:"language,omitempty"` + + // InterruptMode is the interrupt mode of the kernel + InterruptMode string `json:"interrupt_mode,omitempty"` +} + +// Kernel represents a running kernel instance +type Kernel struct { + // ID is the unique identifier of the kernel + ID string `json:"id"` + + // Name is the name of the kernel + Name string `json:"name"` + + // LastActivity is the timestamp of the kernel's last activity + LastActivity time.Time `json:"last_activity,omitempty"` + + // Connections is the number of clients currently connected to the kernel + Connections int `json:"connections,omitempty"` + + // ExecutionState is the execution state of the kernel (e.g., idle, busy) + ExecutionState string `json:"execution_state,omitempty"` +} + +// KernelStartRequest is the request for starting a new kernel +type KernelStartRequest struct { + // Name is the name of the kernel to start + Name string `json:"name"` + + // Path is the optional path for the kernel + Path string `json:"path,omitempty"` +} + +// KernelRestartResponse representsresponse of kernel restart +type KernelRestartResponse struct { + // ID is the ID of the restarted kernel + ID string `json:"id"` + + // Name is the restarted kernel name + Name string `json:"name"` + + // Restarted represents whether the kernel was successfully restarted + Restarted bool `json:"restarted"` + + // LastActivity is the timestamp of the kernel's last activity + LastActivity time.Time `json:"last_activity,omitempty"` +} + +// KernelInterruptRequest request to interrupt a kernel +type KernelInterruptRequest struct { + // Restart represents whether to restart the kernel after interruption + Restart bool `json:"restart,omitempty"` +} + +// KernelStatus represents the status of the kernel +type KernelStatus string + +const ( + // KernelStatusIdle representskernel is idle + KernelStatusIdle KernelStatus = "idle" + + // KernelStatusBusy representskernel is busy + KernelStatusBusy KernelStatus = "busy" + + // KernelStatusStarting representskernel is starting + KernelStatusStarting KernelStatus = "starting" + + // KernelStatusRestarting represents the kernel is restarting + KernelStatusRestarting KernelStatus = "restarting" + + // KernelStatusDead represents the kernel is dead + KernelStatusDead KernelStatus = "dead" +) diff --git a/components/execd/pkg/jupyter/live_integration_test.go b/components/execd/pkg/jupyter/live_integration_test.go new file mode 100644 index 0000000..f03a57d --- /dev/null +++ b/components/execd/pkg/jupyter/live_integration_test.go @@ -0,0 +1,326 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jupyter + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" +) + +// TestLiveServerIntegration tests SDK integration with a real Jupyter server +func TestLiveServerIntegration(t *testing.T) { + // Get configuration from environment variables, use default values if not set + jupyterURL := getEnv("JUPYTER_URL", "") + jupyterToken := getEnv("JUPYTER_TOKEN", "") + if jupyterURL == "" || jupyterToken == "" { + t.Skip("JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test") + } + + // Output test information + t.Logf("Connecting to Jupyter server: %s", jupyterURL) + + // Create HTTP client with authentication capability + httpClient := &http.Client{ + Transport: &AuthTransport{ + Token: jupyterToken, + Base: http.DefaultTransport, + }, + } + + // Create client and set authentication + client := NewClient(jupyterURL, + WithToken(jupyterToken), // Keep Token setting to support ValidateAuth and WebSocket connections + WithHTTPClient(httpClient)) + + // Test 1: Validate authentication + t.Run("Validate Authentication", func(t *testing.T) { + status, err := client.ValidateAuth() + if err != nil { + t.Fatalf("Authentication validation failed: %v", err) + } + if status != "ok" { + t.Errorf("Authentication status incorrect, expected 'ok', got '%s'", status) + } + t.Logf("Authentication validation successful! Status: %s", status) + }) + + // Test 2: Get kernel specs + var kernelName string + t.Run("Get Kernel Specs", func(t *testing.T) { + specs, err := client.GetKernelSpecs() + if err != nil { + t.Fatalf("Failed to get kernel specs: %v", err) + } + if specs.Default == "" { + t.Errorf("No default kernel") + } + if len(specs.Kernelspecs) == 0 { + t.Errorf("No available kernels") + } + + // Use default kernel or Python kernel (if available) + kernelName = specs.Default + for name, spec := range specs.Kernelspecs { + if spec.Spec.Language == "python" { + kernelName = name + break + } + } + + t.Logf("Get kernel specs successful! Default kernel: %s, Selected kernel: %s", specs.Default, kernelName) + t.Logf("Available kernels: %v", specs.Kernelspecs) + }) + + // Test 3: List sessions + t.Run("List Sessions", func(t *testing.T) { + sessions, err := client.ListSessions() + if err != nil { + t.Fatalf("Failed to list sessions: %v", err) + } + t.Logf("List sessions successful! Number of existing sessions: %d", len(sessions)) + for i, s := range sessions { + t.Logf("Session %d: ID=%s, Path=%s, Kernel=%s", i+1, s.ID, s.Path, s.Kernel.Name) + } + }) + + // Test 4: Create new session + var sessionID string + t.Run("Create Session", func(t *testing.T) { + // Generate unique name for test session + sessionName := fmt.Sprintf("test-session-%d", time.Now().Unix()) + sessionPath := "/test-notebook.ipynb" + + session, err := client.CreateSession(sessionName, sessionPath, kernelName) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + if session.ID == "" { + t.Errorf("Created session has no ID") + } + if session.Kernel.ID == "" { + t.Errorf("Created session has no kernel ID") + } + + // Save session ID for subsequent tests + sessionID = session.ID + + t.Logf("Create session successful! Session ID: %s, Kernel ID: %s", session.ID, session.Kernel.ID) + }) + + // Test 5: Get created session + var kernelID string + t.Run("Get Session", func(t *testing.T) { + if sessionID == "" { + t.Skip("No session ID, skipping test") + } + + session, err := client.GetSession(sessionID) + if err != nil { + t.Fatalf("Failed to get session: %v", err) + } + if session.ID != sessionID { + t.Errorf("Session ID mismatch, expected '%s', got '%s'", sessionID, session.ID) + } + + // Save kernel ID for subsequent tests + kernelID = session.Kernel.ID + + t.Logf("Get session successful! Session name: %s, Kernel name: %s", session.Name, session.Kernel.Name) + }) + + // Test 6: List all kernels + t.Run("List Kernels", func(t *testing.T) { + kernels, err := client.ListKernels() + if err != nil { + t.Fatalf("Failed to list kernels: %v", err) + } + t.Logf("List kernels successful! Number of kernels: %d", len(kernels)) + for i, k := range kernels { + t.Logf("Kernel %d: ID=%s, Name=%s, State=%s", i+1, k.ID, k.Name, k.ExecutionState) + } + + // Verify that the created kernel is in the list + if kernelID != "" { + found := false + for _, k := range kernels { + if k.ID == kernelID { + found = true + break + } + } + if !found { + t.Errorf("Cannot find created kernel in kernel list ID=%s", kernelID) + } + } + }) + + // Test 7: Connect to kernel and execute code + t.Run("Execute Code", func(t *testing.T) { + if kernelID == "" { + t.Skip("No kernel ID, skipping test") + } + + // Connect to kernel + err := client.ConnectToKernel(kernelID) + if err != nil { + t.Fatalf("Failed to connect to kernel: %v", err) + } + defer client.DisconnectFromKernel() + + // Execute simple code + code := "print('Hello, Jupyter!')\nresult = 2 + 2\nresult" + t.Logf("Executing code:\n%s", code) + + err = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{}) + if err != nil { + t.Fatalf("Failed to execute code: %v", err) + } + }) + + // Test 7: Connect to kernel and execute code + t.Run("Execute Code", func(t *testing.T) { + if kernelID == "" { + t.Skip("No kernel ID, skipping test") + } + + // Connect to kernel + err := client.ConnectToKernel(kernelID) + if err != nil { + t.Fatalf("Failed to connect to kernel: %v", err) + } + defer client.DisconnectFromKernel() + + // Execute simple code + code := "print(f'2 + 2 = {result}')\nresult" + t.Logf("Executing code:\n%s", code) + + err = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{}) + if err != nil { + t.Fatalf("Failed to execute code: %v", err) + } + }) + + // Test 8: Execute complex code with different types of output + t.Run("Execute Complex Code", func(t *testing.T) { + if kernelID == "" { + t.Skip("No kernel ID, skipping test") + } + + // Connect to kernel + err := client.ConnectToKernel(kernelID) + if err != nil { + t.Fatalf("Failed to connect to kernel: %v", err) + } + defer client.DisconnectFromKernel() + + // Execute code that generates multiple output types + code := ` +# Display table data +import pandas as pd +import numpy as np +try: + df = pd.DataFrame({ + 'A': np.random.rand(5), + 'B': np.random.rand(5) + }) + display(df) + print("DataFrame created successfully") +except Exception as e: + print(f"Error creating DataFrame: {e}") + +# Generate error +try: + print(undefined_variable) +except Exception as e: + print(f"Expected error: {e}") + +# Return dictionary +{'hello': 'world', 'number': 42} +` + + t.Logf("Executing complex code...") + + err = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{}) + if err != nil { + t.Fatalf("Failed to execute complex code: %v", err) + } + }) + + // Test 9: Restart kernel + t.Run("Restart Kernel", func(t *testing.T) { + if kernelID == "" { + t.Skip("No kernel ID, skipping test") + } + + // Restart kernel + restarted, err := client.RestartKernel(kernelID) + if err != nil { + t.Fatalf("Failed to restart kernel: %v", err) + } + + // Wait for kernel restart to complete + time.Sleep(2 * time.Second) + + // Verify kernel state + kernel, err := client.GetKernel(kernelID) + if err != nil { + t.Fatalf("Failed to get kernel: %v", err) + } + + t.Logf("Restart kernel successful! Restart status: %v, Kernel state: %s", restarted, kernel.ExecutionState) + }) + + // Test 10: Close session + t.Run("Close Session", func(t *testing.T) { + if sessionID == "" { + t.Skip("No session ID, skipping test") + } + + // Delete session + err := client.DeleteSession(sessionID) + if err != nil { + t.Fatalf("Failed to delete session: %v", err) + } + + // Verify session is deleted + sessions, err := client.ListSessions() + if err != nil { + t.Fatalf("Failed to list sessions: %v", err) + } + + for _, s := range sessions { + if s.ID == sessionID { + t.Errorf("Session still exists, not properly deleted ID=%s", sessionID) + break + } + } + + t.Logf("Close session successful!") + }) +} + +// Helper function: Get environment variable, use default value if not exists +func getEnv(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + return value +} diff --git a/components/execd/pkg/jupyter/session/session.go b/components/execd/pkg/jupyter/session/session.go new file mode 100644 index 0000000..eb700d0 --- /dev/null +++ b/components/execd/pkg/jupyter/session/session.go @@ -0,0 +1,257 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package session provides functionality for managing Jupyter sessions +package session + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Client is the client for session management +type Client struct { + // baseURL is the base URL of the Jupyter server + baseURL string + + // httpClient is the client for sending HTTP requests, with authentication support + httpClient *http.Client +} + +// NewClient creates a new session management client +func NewClient(baseURL string, httpClient *http.Client) *Client { + return &Client{ + baseURL: baseURL, + httpClient: httpClient, + } +} + +// ListSessions retrieves the list of all active sessions +func (c *Client) ListSessions() ([]*Session, error) { + // Build request URL + url := fmt.Sprintf("%s/api/sessions", c.baseURL) + + // Send GET request + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var sessions []*Session + if err := json.Unmarshal(body, &sessions); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return sessions, nil +} + +// GetSession retrieves information about a specific session +func (c *Client) GetSession(sessionId string) (*Session, error) { + // Build request URL + url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) + + // Send GET request + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var session Session + if err := json.Unmarshal(body, &session); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &session, nil +} + +// CreateSession creates a new session +func (c *Client) CreateSession(name, ipynb, kernel string) (*Session, error) { + // Build request URL + url := fmt.Sprintf("%s/api/sessions", c.baseURL) + + // Build request body + reqBody := &SessionCreateRequest{ + Path: ipynb, + Name: name, + Type: DefaultSessionType, + Kernel: &KernelSpec{ + Name: kernel, + }, + } + + // Serialize request body to JSON + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to serialize request: %w", err) + } + + // Create POST request + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var session Session + if err := json.Unmarshal(body, &session); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &session, nil +} + +// DeleteSession deletes the specified session +func (c *Client) DeleteSession(sessionId string) error { + // Build request URL + url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) + + // Create DELETE request + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + return nil +} + +// CreateSessionWithOptions usingoption to create a new session +func (c *Client) CreateSessionWithOptions(options *SessionOptions) (*Session, error) { + // Build request URL + url := fmt.Sprintf("%s/api/sessions", c.baseURL) + + // Build request body + reqBody := &SessionCreateRequest{ + Path: options.Path, + Name: options.Name, + } + + // set session type + if options.Type != "" { + reqBody.Type = options.Type + } else { + reqBody.Type = DefaultSessionType + } + + // set kernel information + if options.KernelID != "" { + // If kernel ID is provided, use existing kernel + reqBody.Kernel = &KernelSpec{ + ID: options.KernelID, + } + } else if options.KernelName != "" { + // If kernel name is provided, start new kernel + reqBody.Kernel = &KernelSpec{ + Name: options.KernelName, + } + } + + // Serialize request body to JSON + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to serialize request: %w", err) + } + + // Create POST request + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) + } + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse JSON response + var session Session + if err := json.Unmarshal(body, &session); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &session, nil +} diff --git a/components/execd/pkg/jupyter/session/session_test.go b/components/execd/pkg/jupyter/session/session_test.go new file mode 100644 index 0000000..66654b8 --- /dev/null +++ b/components/execd/pkg/jupyter/session/session_test.go @@ -0,0 +1,243 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package session + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// Test listing sessions +func TestListSessions(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != http.MethodGet { + t.Errorf("expected request method GET, got %s", r.Method) + } + if r.URL.Path != "/api/sessions" { + t.Errorf("expected request path /api/sessions, got %s", r.URL.Path) + } + + // Return mocked session list + response := `[ + { + "id": "session-1", + "path": "/path/to/notebook1.ipynb", + "name": "Session 1", + "type": "notebook", + "kernel": { + "id": "kernel-1", + "name": "python3", + "last_activity": "2023-01-01T00:00:00Z", + "execution_state": "idle", + "connections": 1 + } + }, + { + "id": "session-2", + "path": "/path/to/notebook2.ipynb", + "name": "Session 2", + "type": "notebook", + "kernel": { + "id": "kernel-2", + "name": "python3", + "last_activity": "2023-01-01T00:00:00Z", + "execution_state": "idle", + "connections": 1 + } + } + ]` + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, &http.Client{}) + + // Fetch session list + sessions, err := client.ListSessions() + if err != nil { + t.Fatalf("failed to list sessions: %v", err) + } + + // Validate session count + if len(sessions) != 2 { + t.Errorf("expected 2 sessions, got %d", len(sessions)) + } + + // Validate first session fields + if sessions[0].ID != "session-1" { + t.Errorf("expected session ID 'session-1', got '%s'", sessions[0].ID) + } + if sessions[0].Name != "Session 1" { + t.Errorf("expected session name 'Session 1', got '%s'", sessions[0].Name) + } + if sessions[0].Path != "/path/to/notebook1.ipynb" { + t.Errorf("expected session path '/path/to/notebook1.ipynb', got '%s'", sessions[0].Path) + } + if sessions[0].Type != "notebook" { + t.Errorf("expected session type 'notebook', got '%s'", sessions[0].Type) + } + + // Validate first session kernel fields + if sessions[0].Kernel.ID != "kernel-1" { + t.Errorf("expected kernel ID 'kernel-1', got '%s'", sessions[0].Kernel.ID) + } + if sessions[0].Kernel.Name != "python3" { + t.Errorf("expected kernel name 'python3', got '%s'", sessions[0].Kernel.Name) + } +} + +// Test creating session +func TestCreateSession(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != http.MethodPost { + t.Errorf("expected request method POST, got %s", r.Method) + } + if r.URL.Path != "/api/sessions" { + t.Errorf("expected request path /api/sessions, got %s", r.URL.Path) + } + + // Parse request body + var requestBody SessionCreateRequest + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&requestBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + + // Validate request params + if requestBody.Name != "Test Session" { + t.Errorf("expected session name 'Test Session', got '%s'", requestBody.Name) + } + if requestBody.Path != "/path/to/notebook.ipynb" { + t.Errorf("expected session path '/path/to/notebook.ipynb', got '%s'", requestBody.Path) + } + if requestBody.Type != "notebook" { + t.Errorf("expected session type 'notebook', got '%s'", requestBody.Type) + } + if requestBody.Kernel.Name != "python3" { + t.Errorf("expected kernel name 'python3', got '%s'", requestBody.Kernel.Name) + } + + // Return mocked create response + response := `{ + "id": "new-session-id", + "path": "/path/to/notebook.ipynb", + "name": "Test Session", + "type": "notebook", + "kernel": { + "id": "new-kernel-id", + "name": "python3", + "last_activity": "2023-01-01T00:00:00Z", + "execution_state": "idle", + "connections": 0 + } + }` + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(response)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, &http.Client{}) + + // Create session + newSession, err := client.CreateSession("Test Session", "/path/to/notebook.ipynb", "python3") + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + // Validate created session + if newSession.ID != "new-session-id" { + t.Errorf("expected session ID 'new-session-id', got '%s'", newSession.ID) + } + if newSession.Name != "Test Session" { + t.Errorf("expected session name 'Test Session', got '%s'", newSession.Name) + } + if newSession.Path != "/path/to/notebook.ipynb" { + t.Errorf("expected session path '/path/to/notebook.ipynb', got '%s'", newSession.Path) + } + if newSession.Kernel.ID != "new-kernel-id" { + t.Errorf("expected kernel ID 'new-kernel-id', got '%s'", newSession.Kernel.ID) + } +} + +// Test fetching a specific session +func TestGetSession(t *testing.T) { + sessionID := "test-session-id" + + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != http.MethodGet { + t.Errorf("expected request method GET, got %s", r.Method) + } + + expectedPath := "/api/sessions/" + sessionID + if r.URL.Path != expectedPath { + t.Errorf("expected request path '%s', got '%s'", expectedPath, r.URL.Path) + } + + // Return mocked session + response := `{ + "id": "test-session-id", + "path": "/path/to/notebook.ipynb", + "name": "Test Session", + "type": "notebook", + "kernel": { + "id": "test-kernel-id", + "name": "python3", + "last_activity": "2023-01-01T00:00:00Z", + "execution_state": "idle", + "connections": 1 + } + }` + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + // Create client + client := NewClient(server.URL, &http.Client{}) + + // Fetch session + session, err := client.GetSession(sessionID) + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + + // Validate session + if session.ID != sessionID { + t.Errorf("expected session ID '%s', got '%s'", sessionID, session.ID) + } + if session.Name != "Test Session" { + t.Errorf("expected session name 'Test Session', got '%s'", session.Name) + } + if session.Kernel.ID != "test-kernel-id" { + t.Errorf("expected kernel ID 'test-kernel-id', got '%s'", session.Kernel.ID) + } +} diff --git a/components/execd/pkg/jupyter/session/sessions.json b/components/execd/pkg/jupyter/session/sessions.json new file mode 100644 index 0000000..7d48452 --- /dev/null +++ b/components/execd/pkg/jupyter/session/sessions.json @@ -0,0 +1,97 @@ +[ { + "id" : "cb1baca9-a60e-4937-a1d0-18bc1fc45e60", + "path" : "my_notebook.ipynb", + "name" : "my_session", + "type" : "notebook", + "kernel" : { + "id" : "d7052326-5c98-4575-bb18-a7902ef5f623", + "name" : "python3", + "last_activity" : "2025-06-05T09:09:54.420827Z", + "execution_state" : "idle", + "connections" : 0 + }, + "notebook" : { + "path" : "my_notebook.ipynb", + "name" : "my_session" + } +}, { + "id" : "a3378ca1-ba62-4341-9db5-3bc612fb3517", + "path" : "Untitled.ipynb", + "name" : "Untitled.ipynb", + "type" : "notebook", + "kernel" : { + "id" : "d7052326-5c98-4575-bb18-a7902ef5f623", + "name" : "python3", + "last_activity" : "2025-06-05T09:09:54.420827Z", + "execution_state" : "idle", + "connections" : 0 + }, + "notebook" : { + "path" : "Untitled.ipynb", + "name" : "Untitled.ipynb" + } +}, { + "id" : "c4829f29-8430-4dce-b1f5-9d2ac6c4f570", + "path" : "/tmp/example_notebook.ipynb", + "name" : "example_session", + "type" : "notebook", + "kernel" : { + "id" : "00349e07-3877-4eb0-a676-0df5b886d770", + "name" : "python3", + "last_activity" : "2025-06-05T11:51:22.194821Z", + "execution_state" : "starting", + "connections" : 0 + }, + "notebook" : { + "path" : "/tmp/example_notebook.ipynb", + "name" : "example_session" + } +}, { + "id" : "9a8e1857-b737-41a6-8f81-6039f6ae0ac1", + "path" : "e0ebd37c-578a-443c-8f58-236984aea7ff", + "name" : "session_5c4e8183-9e8a-4879-93b2-5622518193d7", + "type" : "notebook", + "kernel" : { + "id" : "e8792c3e-3190-4b11-92e8-b7ec9ef44da9", + "name" : "python3", + "last_activity" : "2025-06-05T12:26:01.610210Z", + "execution_state" : "starting", + "connections" : 0 + }, + "notebook" : { + "path" : "e0ebd37c-578a-443c-8f58-236984aea7ff", + "name" : "session_5c4e8183-9e8a-4879-93b2-5622518193d7" + } +}, { + "id" : "cc06c06d-4f6b-45a5-a546-11a5b5f246f8", + "path" : "notebook.ipynb", + "name" : null, + "type" : "notebook", + "kernel" : { + "id" : "62e7fd9e-ea50-4045-861b-3a5a7073ee22", + "name" : "python3", + "last_activity" : "2025-06-05T12:26:51.714871Z", + "execution_state" : "starting", + "connections" : 0 + }, + "notebook" : { + "path" : "notebook.ipynb", + "name" : null + } +}, { + "id" : "db123df4-ec13-4fe0-b3c3-ef85464b8a42", + "path" : "/tmp/test.ipynb", + "name" : "", + "type" : "notebook", + "kernel" : { + "id" : "7d3091af-8b0a-474a-be04-f64191a43d0f", + "name" : "python3", + "last_activity" : "2025-06-06T01:29:16.712732Z", + "execution_state" : "starting", + "connections" : 0 + }, + "notebook" : { + "path" : "/tmp/test.ipynb", + "name" : "" + } +} ] diff --git a/components/execd/pkg/jupyter/session/types.go b/components/execd/pkg/jupyter/session/types.go new file mode 100644 index 0000000..9003289 --- /dev/null +++ b/components/execd/pkg/jupyter/session/types.go @@ -0,0 +1,110 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package session provides functionality for managing Jupyter sessions +package session + +import ( + "time" +) + +// Session represents a Jupyter session +type Session struct { + // ID is the unique identifier of the session + ID string `json:"id"` + + // Path is the path associated with the session (typically the notebook file path) + Path string `json:"path"` + + // Name is the name of the session + Name string `json:"name"` + + // Type is the type of the session (e.g., notebook, console) + Type string `json:"type"` + + // Kernel contains information about the kernel associated with the session + Kernel *KernelInfo `json:"kernel"` + + // CreatedAt is the timestamp when the session was created + CreatedAt time.Time `json:"created,omitempty"` + + // LastModified is the timestamp when the session was last modified + LastModified time.Time `json:"last_modified,omitempty"` +} + +// KernelInfo contains basic kernel information +type KernelInfo struct { + // ID is the unique identifier of the kernel + ID string `json:"id"` + + // Name is the name of the kernel (e.g., python3, ir) + Name string `json:"name"` + + // LastActivity is the timestamp of the kernel's last activity + LastActivity time.Time `json:"last_activity,omitempty"` + + // Connections is the number of clients currently connected to the kernel + Connections int `json:"connections,omitempty"` + + // ExecutionState is the execution state of the kernel (e.g., idle, busy) + ExecutionState string `json:"execution_state,omitempty"` +} + +// SessionCreateRequest is the request for creating a new session +type SessionCreateRequest struct { + // Path is the path associated with the session (typically the notebook file path) + Path string `json:"path"` + + // Name is the name of the session + Name string `json:"name,omitempty"` + + // Type is the type of the session (defaults to "notebook") + Type string `json:"type,omitempty"` + + // Kernel contains information about the kernel to start + Kernel *KernelSpec `json:"kernel,omitempty"` +} + +// KernelSpec contains kernel specification information +type KernelSpec struct { + // Name is the name of the kernel (e.g., python3, ir) + Name string `json:"name"` + + // ID is the unique identifier of the kernel (optional, used only when reusing existing kernel) + ID string `json:"id,omitempty"` +} + +// SessionListResponse represents the response for listing sessions +type SessionListResponse []*Session + +// SessionOptions contains options for creating or updating sessions +type SessionOptions struct { + // Name is the name of the session + Name string + + // Path is the path associated with the session + Path string + + // Type is the type of the session (defaults to "notebook") + Type string + + // KernelName is the kernel name to use (e.g., python3, ir, etc.) + KernelName string + + // KernelID is the ID of the existing kernel to reuse (if provided, KernelName will be ignored) + KernelID string +} + +// DefaultSessionType is the default session type +const DefaultSessionType = "notebook" diff --git a/components/execd/pkg/jupyter/transport.go b/components/execd/pkg/jupyter/transport.go new file mode 100644 index 0000000..62599ef --- /dev/null +++ b/components/execd/pkg/jupyter/transport.go @@ -0,0 +1,49 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jupyter + +import ( + "net/http" + "sync" +) + +type AuthTransport struct { + Token string + Base http.RoundTripper + + host string + scheme string + mu sync.Mutex +} + +func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqClone := req.Clone(req.Context()) + allowAuth := true + if reqClone.URL != nil { + t.mu.Lock() + if t.host == "" { + t.host = reqClone.URL.Host + } + if t.scheme == "" { + t.scheme = reqClone.URL.Scheme + } + allowAuth = reqClone.URL.Host == t.host && reqClone.URL.Scheme == t.scheme + t.mu.Unlock() + } + if allowAuth { + reqClone.Header.Set("Authorization", "Token "+t.Token) + } + return t.Base.RoundTrip(reqClone) +} diff --git a/components/execd/pkg/log/log.go b/components/execd/pkg/log/log.go new file mode 100644 index 0000000..27a6173 --- /dev/null +++ b/components/execd/pkg/log/log.go @@ -0,0 +1,85 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "context" + "os" + + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/alibaba/opensandbox/internal/safego" +) + +const logFileEnvKey = "EXECD_LOG_FILE" + +var current slogger.Logger + +// Init constructs the singleton logger. Call once during startup. +// Legacy levels: 0/1/2=fatal, 3=error, 4=warn, 5/6=info, 7+=debug. +func Init(level int) { + current = newLogger(mapLevel(level)) + safego.InitPanicLogger(context.Background(), current) +} + +func mapLevel(level int) string { + switch { + case level <= 2: + return "fatal" + case level == 3: + return "error" + case level == 4: + return "warn" + case level == 5 || level == 6: + return "info" + default: + return "debug" + } +} + +func newLogger(level string) slogger.Logger { + cfg := slogger.Config{ + Level: level, + } + if logFile := os.Getenv(logFileEnvKey); logFile != "" { + cfg.OutputPaths = []string{logFile} + cfg.ErrorOutputPaths = cfg.OutputPaths + } + return slogger.MustNew(cfg) +} + +func getLogger() slogger.Logger { + if current != nil { + return current + } + l := newLogger("info") + current = l + return l +} + +func Debug(format string, args ...any) { + getLogger().Debugf(format, args...) +} + +func Info(format string, args ...any) { + getLogger().Infof(format, args...) +} + +func Warn(format string, args ...any) { + getLogger().Warnf(format, args...) +} + +func Error(format string, args ...any) { + getLogger().Errorf(format, args...) +} diff --git a/components/execd/pkg/log/sanitize.go b/components/execd/pkg/log/sanitize.go new file mode 100644 index 0000000..484975c --- /dev/null +++ b/components/execd/pkg/log/sanitize.go @@ -0,0 +1,119 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import "regexp" + +var sanitizePatterns = []struct { + re *regexp.Regexp + repl string +}{ + // === URL / header / connection-string patterns === + + // URL credentials: scheme://user:password@host + { + regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/\s:@]+:[^/\s:@]+@`), + `${1}****:****@`, + }, + // Authorization: Bearer/Basic/Token/ApiKey header value + { + regexp.MustCompile(`(?i)(Authorization\s*:\s*(?:Bearer|Basic|Token|ApiKey|apiKey)\s+)\S+`), + `${1}****`, + }, + // Azure storage AccountKey / SharedAccessKey (stop at ; " ' whitespace) + { + regexp.MustCompile(`((?:Account|SharedAccess)Key\s*=\s*)[^;"'\s]+`), + `${1}****`, + }, + + // === Long-flag sensitive options: --flag value or --flag=value === + // Flag names listed longest-first so RE2 picks the right match. + // Requiring [\s=] after the flag name prevents --password from matching + // --password-stdin (the trailing - isn't whitespace or =). + + { + regexp.MustCompile( + `(--(?:access-key-id|access-key-secret|access-token|access-key|` + + `secret-access-key|secret-key|secret-id|secret|` + + `aws-secret-access-key|private-key|client-secret|refresh-token|` + + `sentry-token|credential|password|passwd|` + + `api-key|apikey|token|` + + `ak|sk))` + + `([\s=])\s*` + + `(?:"[^"]*"|'[^']*'|\S+)?`, + ), + `${1}${2}****`, + }, + + // === Environment variable assignments with sensitive names === + + { + regexp.MustCompile( + `\b(PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|APIKEY|AUTH_TOKEN|ACCESS_TOKEN|` + + `PRIVATE_KEY|CLIENT_SECRET|REFRESH_TOKEN|CREDENTIAL|AUTH_KEY|SECRET_KEY|SECRET_ID|` + + // Cloud-provider specific + `ACCESS_KEY_ID|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY|` + + `AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|` + + `ALIBABA_CLOUD_ACCESS_KEY_ID|ALIBABA_CLOUD_ACCESS_KEY_SECRET|ALIBABA_CLOUD_SECRET_KEY|` + + `ALICLOUD_ACCESS_KEY_ID|ALICLOUD_ACCESS_KEY_SECRET|ALICLOUD_SECRET_KEY|` + + `TENCENTCLOUD_SECRET_ID|TENCENTCLOUD_SECRET_KEY|` + + `HUAWEICLOUD_ACCESS_KEY_ID|HUAWEICLOUD_SECRET_ACCESS_KEY|` + + `CLOUD_ACCESS_KEY_ID|CLOUD_SECRET_KEY|CLOUD_API_KEY|CLOUD_API_SECRET|` + + `RAM_ACCESS_KEY_ID|RAM_ACCESS_KEY_SECRET|` + + `OTS_ACCESS_KEY_ID|OTS_ACCESS_KEY_SECRET|` + + `ACCOUNT_KEY|SHARED_ACCESS_KEY|AZURE_STORAGE_KEY|` + + `GCP_CREDENTIALS|GOOGLE_APPLICATION_CREDENTIALS|` + + `DOCKER_PASSWORD|DOCKER_TOKEN|REGISTRY_PASSWORD` + + `)\s*=\s*(?:"[^"]*"|'[^']*'|\S+)`, + ), + `${1}=****`, + }, + + // === Bare cloud access key matching (prefix-specific, low false-positive) === + + // Alibaba Cloud AccessKey ID: LTAI + 16~32 alphanumeric + { + regexp.MustCompile(`\bLTAI[a-zA-Z0-9]{16,32}\b`), + `LTAI****`, + }, + // AWS Access Key ID: AKIA + 16 uppercase + { + regexp.MustCompile(`\bAKIA[A-Z0-9]{16}\b`), + `AKIA****`, + }, + // Tencent Cloud Secret ID: AKID + 16~48 alphanumeric + { + regexp.MustCompile(`\bAKID[a-zA-Z0-9]{16,48}\b`), + `AKID****`, + }, +} + +// SanitizeCommand masks sensitive values (passwords, tokens, keys, credentials) +// in a shell command string so it is safe to log. +func SanitizeCommand(cmd string) string { + for _, p := range sanitizePatterns { + cmd = p.re.ReplaceAllString(cmd, p.repl) + } + return cmd +} + +// MaskToken returns a partially masked token for logging. +// Shows first 4 and last 4 characters; returns "****" for tokens 8 chars or shorter. +func MaskToken(token string) string { + if len(token) <= 8 { + return "****" + } + return token[:4] + "****" + token[len(token)-4:] +} diff --git a/components/execd/pkg/runtime/bash_session.go b/components/execd/pkg/runtime/bash_session.go new file mode 100644 index 0000000..f1f1b9c --- /dev/null +++ b/components/execd/pkg/runtime/bash_session.go @@ -0,0 +1,479 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/google/uuid" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" +) + +const ( + envDumpStartMarker = "__ENV_DUMP_START__" + envDumpEndMarker = "__ENV_DUMP_END__" + exitMarkerPrefix = "__EXIT_CODE__:" + pwdMarkerPrefix = "__PWD__:" +) + +func (c *Controller) createBashSession(req *CreateContextRequest) (string, error) { + resolvedCwd, err := pathutil.ExpandPath(req.Cwd) + if err != nil { + return "", fmt.Errorf("resolve request cwd %s: %w", req.Cwd, err) + } + if resolvedCwd != "" { + err := os.MkdirAll(resolvedCwd, os.ModePerm) + if err != nil { + return "", err + } + } + + session := newBashSession(resolvedCwd) + if err := session.start(); err != nil { + return "", fmt.Errorf("failed to start bash session: %w", err) + } + + c.bashSessionClientMap.Store(session.config.Session, session) + log.Info("created bash session %s", session.config.Session) + return session.config.Session, nil +} + +func (c *Controller) runBashSession(ctx context.Context, request *ExecuteCodeRequest) error { + session := c.getBashSession(request.Context) + if session == nil { + return ErrContextNotFound + } + + return session.run(ctx, request) +} + +func (c *Controller) getBashSession(sessionId string) *bashSession { + if v, ok := c.bashSessionClientMap.Load(sessionId); ok { + if s, ok := v.(*bashSession); ok { + return s + } + } + return nil +} + +func (c *Controller) closeBashSession(sessionId string) error { + session := c.getBashSession(sessionId) + if session == nil { + return ErrContextNotFound + } + + err := session.close() + if err != nil { + return err + } + + c.bashSessionClientMap.Delete(sessionId) + return nil +} + +func (c *Controller) CreateBashSession(req *CreateContextRequest) (string, error) { + return c.createBashSession(req) +} + +func (c *Controller) RunInBashSession(ctx context.Context, req *ExecuteCodeRequest) error { + return c.runBashSession(ctx, req) +} + +func (c *Controller) DeleteBashSession(sessionID string) error { + return c.closeBashSession(sessionID) +} + +func newBashSession(cwd string) *bashSession { + config := &bashSessionConfig{ + Session: uuidString(), + StartupTimeout: 5 * time.Second, + } + + env := make(map[string]string) + for _, kv := range os.Environ() { + if k, v, ok := splitEnvPair(kv); ok { + env[k] = v + } + } + + return &bashSession{ + config: config, + env: env, + cwd: cwd, + } +} + +func (s *bashSession) start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.started { + return errors.New("session already started") + } + + s.started = true + return nil +} + +func (s *bashSession) trackCurrentProcess(pid int) { + s.mu.Lock() + defer s.mu.Unlock() + s.currentProcessPid = pid +} + +func (s *bashSession) untrackCurrentProcess() { + s.mu.Lock() + defer s.mu.Unlock() + s.currentProcessPid = 0 +} + +//nolint:gocognit +func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) error { + s.mu.Lock() + if !s.started { + s.mu.Unlock() + return errors.New("session not started") + } + + envSnapshot := copyEnvMap(s.env) + + cwd := s.cwd + // override original cwd if specified + if request.Cwd != "" { + expandedCwd, err := pathutil.ExpandPath(request.Cwd) + if err != nil { + s.mu.Unlock() + return fmt.Errorf("resolve cwd: %w", err) + } + cwd = expandedCwd + } + sessionID := s.config.Session + s.mu.Unlock() + + startAt := time.Now() + if request.Hooks.OnExecuteInit != nil { + request.Hooks.OnExecuteInit(sessionID) + } + + wait := request.Timeout + if wait <= 0 { + wait = 24 * 3600 * time.Second // max to 24 hours + } + + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + + script := buildWrappedScript(request.Code, envSnapshot, cwd) + scriptFile, err := os.CreateTemp("", "execd_bash_*.sh") + if err != nil { + return fmt.Errorf("create script file: %w", err) + } + scriptPath := scriptFile.Name() + if _, err := scriptFile.WriteString(script); err != nil { + _ = scriptFile.Close() + return fmt.Errorf("write script file: %w", err) + } + if err := scriptFile.Close(); err != nil { + return fmt.Errorf("close script file: %w", err) + } + + cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", scriptPath) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large. + // Child inherits parent env (nil => default in Go). The script file already has "export K=V" for + // all session vars at the top, so the session environment is applied when the script runs. + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout pipe: %w", err) + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + log.Error("start bash session failed: %v (command: %q)", err, log.SanitizeCommand(request.Code)) + return fmt.Errorf("start bash: %w", err) + } + defer s.untrackCurrentProcess() + s.trackCurrentProcess(cmd.Process.Pid) + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + var ( + envLines []string + pwdLine string + exitCode *int + inEnv bool + ) + + for scanner.Scan() { + line := scanner.Text() + switch { + case line == envDumpStartMarker: + inEnv = true + case line == envDumpEndMarker: + inEnv = false + case strings.HasPrefix(line, exitMarkerPrefix): + if code, err := strconv.Atoi(strings.TrimPrefix(line, exitMarkerPrefix)); err == nil { + exitCode = &code //nolint:ineffassign + } + case strings.HasPrefix(line, pwdMarkerPrefix): + pwdLine = strings.TrimPrefix(line, pwdMarkerPrefix) + default: + if inEnv { + envLines = append(envLines, line) + continue + } + if request.Hooks.OnExecuteStdout != nil { + request.Hooks.OnExecuteStdout(line) + } + } + } + + scanErr := scanner.Err() + waitErr := cmd.Wait() + + if scanErr != nil { + log.Error("read stdout failed: %v (command: %q)", scanErr, log.SanitizeCommand(request.Code)) + return fmt.Errorf("read stdout: %w", scanErr) + } + + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + log.Error("timeout after %s while running command: %q", wait, log.SanitizeCommand(request.Code)) + return fmt.Errorf("timeout after %s", wait) + } + + if exitCode == nil && cmd.ProcessState != nil { + code := cmd.ProcessState.ExitCode() //nolint:staticcheck + exitCode = &code //nolint:ineffassign + } + + updatedEnv := parseExportDump(envLines) + s.mu.Lock() + if len(updatedEnv) > 0 { + s.env = updatedEnv + } + if pwdLine != "" { + s.cwd = pwdLine + } + s.mu.Unlock() + + var exitErr *exec.ExitError + if waitErr != nil && !errors.As(waitErr, &exitErr) { + log.Error("command wait failed: %v (command: %q)", waitErr, log.SanitizeCommand(request.Code)) + return waitErr + } + + userExitCode := 0 + if exitCode != nil { + userExitCode = *exitCode + } + + if userExitCode != 0 { + errMsg := fmt.Sprintf("command exited with code %d", userExitCode) + if waitErr != nil { + errMsg = waitErr.Error() + } + if request.Hooks.OnExecuteError != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: "CommandExecError", + EValue: strconv.Itoa(userExitCode), + Traceback: []string{errMsg}, + }) + } + log.Error("CommandExecError: %s (command: %q)", errMsg, log.SanitizeCommand(request.Code)) + return nil + } + + if request.Hooks.OnExecuteComplete != nil { + request.Hooks.OnExecuteComplete(time.Since(startAt)) + } + + return nil +} + +func buildWrappedScript(command string, env map[string]string, cwd string) string { + var b strings.Builder + + keys := make([]string, 0, len(env)) + for k := range env { + v := env[k] + if isValidEnvKey(k) && !envKeysNotPersisted[k] && len(v) <= maxPersistedEnvValueSize { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString("export ") + b.WriteString(k) + b.WriteString("=") + b.WriteString(shellEscape(env[k])) + b.WriteString("\n") + } + + if cwd != "" { + b.WriteString("cd ") + b.WriteString(shellEscape(cwd)) + b.WriteString("\n") + } + + b.WriteString(command) + if !strings.HasSuffix(command, "\n") { + b.WriteString("\n") + } + + b.WriteString("__USER_EXIT_CODE__=$?\n") + b.WriteString("printf \"\\n%s\\n\" \"" + envDumpStartMarker + "\"\n") + b.WriteString("export -p\n") + b.WriteString("printf \"%s\\n\" \"" + envDumpEndMarker + "\"\n") + b.WriteString("printf \"" + pwdMarkerPrefix + "%s\\n\" \"$(pwd)\"\n") + b.WriteString("printf \"" + exitMarkerPrefix + "%s\\n\" \"$__USER_EXIT_CODE__\"\n") + b.WriteString("exit \"$__USER_EXIT_CODE__\"\n") + + return b.String() +} + +// envKeysNotPersisted are not carried across runs (prompt/display vars). +var envKeysNotPersisted = map[string]bool{ + "PS1": true, "PS2": true, "PS3": true, "PS4": true, + "PROMPT_COMMAND": true, +} + +// maxPersistedEnvValueSize caps single env value length as a safeguard. +const maxPersistedEnvValueSize = 8 * 1024 + +func parseExportDump(lines []string) map[string]string { + if len(lines) == 0 { + return nil + } + env := make(map[string]string, len(lines)) + for _, line := range lines { + k, v, ok := parseExportLine(line) + if !ok || envKeysNotPersisted[k] || len(v) > maxPersistedEnvValueSize { + continue + } + env[k] = v + } + return env +} + +func parseExportLine(line string) (string, string, bool) { + const prefix = "declare -x " + if !strings.HasPrefix(line, prefix) { + return "", "", false + } + rest := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if rest == "" { + return "", "", false + } + name, value := rest, "" + if eq := strings.Index(rest, "="); eq >= 0 { + name = rest[:eq] + raw := rest[eq+1:] + if unquoted, err := strconv.Unquote(raw); err == nil { + value = unquoted + } else { + value = strings.Trim(raw, `"`) + } + } + if !isValidEnvKey(name) { + return "", "", false + } + return name, value, true +} + +func shellEscape(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func isValidEnvKey(key string) bool { + if key == "" { + return false + } + + for i, r := range key { + if i == 0 { + if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && r != '_' { + return false + } + continue + } + if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && (r < '0' || r > '9') && r != '_' { + return false + } + } + + return true +} + +func copyEnvMap(src map[string]string) map[string]string { + if src == nil { + return map[string]string{} + } + + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func splitEnvPair(kv string) (string, string, bool) { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return "", "", false + } + if !isValidEnvKey(parts[0]) { + return "", "", false + } + return parts[0], parts[1], true +} + +func (s *bashSession) close() error { + s.mu.Lock() + defer s.mu.Unlock() + + pid := s.currentProcessPid + s.currentProcessPid = 0 + s.started = false + s.env = nil + s.cwd = "" + + if pid != 0 { + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + log.Warn("kill session process group %d: %v (process may have already exited)", pid, err) + } + } + return nil +} + +func uuidString() string { + return uuid.New().String() +} diff --git a/components/execd/pkg/runtime/bash_session_test.go b/components/execd/pkg/runtime/bash_session_test.go new file mode 100644 index 0000000..d054f0f --- /dev/null +++ b/components/execd/pkg/runtime/bash_session_test.go @@ -0,0 +1,600 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "context" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/internal/safego" +) + +func TestBashSession_NonZeroExitEmitsError(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + sessionID string + stdoutLine string + errCh = make(chan *execute.ErrorOutput, 1) + completeCh = make(chan struct{}, 1) + ) + + req := &ExecuteCodeRequest{ + Language: Bash, + Code: `echo "before"; exit 7`, + Cwd: t.TempDir(), + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { sessionID = s }, + OnExecuteStdout: func(s string) { stdoutLine = s }, + OnExecuteError: func(err *execute.ErrorOutput) { errCh <- err }, + OnExecuteComplete: func(_ time.Duration) { + completeCh <- struct{}{} + }, + }, + } + + session, err := c.createBashSession(&CreateContextRequest{}) + assert.NoError(t, err) + req.Context = session + require.NoError(t, c.runBashSession(ctx, req)) + + var gotErr *execute.ErrorOutput + select { + case gotErr = <-errCh: + case <-time.After(2 * time.Second): + require.Fail(t, "expected error hook to be called") + } + require.NotNil(t, gotErr, "expected non-nil error output") + require.Equal(t, "CommandExecError", gotErr.EName) + require.Equal(t, "7", gotErr.EValue) + require.NotEmpty(t, sessionID, "expected session id to be set") + require.Equal(t, "before", stdoutLine) + + select { + case <-completeCh: + require.Fail(t, "did not expect completion hook on non-zero exit") + default: + } +} + +func TestBashSession_envAndExitCode(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + var ( + initCalls int + completeCalls int + stdoutLines []string + ) + + hooks := ExecuteResultHook{ + OnExecuteInit: func(ctx string) { + require.Equal(t, session.config.Session, ctx, "unexpected session in OnExecuteInit") + initCalls++ + }, + OnExecuteStdout: func(text string) { + t.Log(text) + stdoutLines = append(stdoutLines, text) + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalls++ + }, + } + + // 1) export an env var + request := &ExecuteCodeRequest{ + Code: "export FOO=hello", + Hooks: hooks, + Timeout: 3 * time.Second, + } + require.NoError(t, session.run(context.Background(), request)) + exportStdoutCount := len(stdoutLines) + + // 2) verify env is persisted + request = &ExecuteCodeRequest{ + Code: "echo $FOO", + Hooks: hooks, + Timeout: 3 * time.Second, + } + require.NoError(t, session.run(context.Background(), request)) + echoLines := stdoutLines[exportStdoutCount:] + foundHello := false + for _, line := range echoLines { + if strings.TrimSpace(line) == "hello" { + foundHello = true + break + } + } + require.True(t, foundHello, "expected echo $FOO to output 'hello', got %v", echoLines) + + // 3) ensure exit code of previous command is reflected in shell state + request = &ExecuteCodeRequest{ + Code: "false; echo EXIT:$?", + Hooks: hooks, + Timeout: 3 * time.Second, + } + prevCount := len(stdoutLines) + require.NoError(t, session.run(context.Background(), request)) + exitLines := stdoutLines[prevCount:] + foundExit := false + for _, line := range exitLines { + if strings.Contains(line, "EXIT:1") { + foundExit = true + break + } + } + require.True(t, foundExit, "expected exit code output 'EXIT:1', got %v", exitLines) + require.Equal(t, 3, initCalls, "OnExecuteInit expected 3 calls") + require.Equal(t, 3, completeCalls, "OnExecuteComplete expected 3 calls") +} + +func TestBashSession_envLargeOutputChained(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + var ( + initCalls int + completeCalls int + stdoutLines []string + ) + + hooks := ExecuteResultHook{ + OnExecuteInit: func(ctx string) { + require.Equal(t, session.config.Session, ctx, "unexpected session in OnExecuteInit") + initCalls++ + }, + OnExecuteStdout: func(text string) { + t.Log(text) + stdoutLines = append(stdoutLines, text) + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalls++ + }, + } + + runAndCollect := func(cmd string) []string { + start := len(stdoutLines) + request := &ExecuteCodeRequest{ + Code: cmd, + Hooks: hooks, + Timeout: 10 * time.Second, + } + require.NoError(t, session.run(context.Background(), request)) + return append([]string(nil), stdoutLines[start:]...) + } + + lines1 := runAndCollect("export FOO=hello1; for i in $(seq 1 60); do echo A${i}:$FOO; done") + require.GreaterOrEqual(t, len(lines1), 60, "expected >=60 lines for cmd1") + require.True(t, containsLine(lines1, "A1:hello1") && containsLine(lines1, "A60:hello1"), "env not reflected in cmd1 output, got %v", lines1[:3]) + + lines2 := runAndCollect("export FOO=${FOO}_next; export BAR=bar1; for i in $(seq 1 60); do echo B${i}:$FOO:$BAR; done") + require.GreaterOrEqual(t, len(lines2), 60, "expected >=60 lines for cmd2") + require.True(t, containsLine(lines2, "B1:hello1_next:bar1") && containsLine(lines2, "B60:hello1_next:bar1"), "env not propagated to cmd2 output, sample %v", lines2[:3]) + + lines3 := runAndCollect("export BAR=${BAR}_last; for i in $(seq 1 60); do echo C${i}:$FOO:$BAR; done; echo FINAL_FOO=$FOO; echo FINAL_BAR=$BAR") + require.GreaterOrEqual(t, len(lines3), 62, "expected >=62 lines for cmd3") // 60 lines + 2 finals + require.True(t, containsLine(lines3, "C1:hello1_next:bar1_last") && containsLine(lines3, "C60:hello1_next:bar1_last"), "env not propagated to cmd3 output, sample %v", lines3[:3]) + require.True(t, containsLine(lines3, "FINAL_FOO=hello1_next") && containsLine(lines3, "FINAL_BAR=bar1_last"), "final env lines missing, got %v", lines3[len(lines3)-5:]) + require.Equal(t, 3, initCalls, "OnExecuteInit expected 3 calls") + require.Equal(t, 3, completeCalls, "OnExecuteComplete expected 3 calls") +} + +func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + targetDir := t.TempDir() + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + runAndCollect := func(req *ExecuteCodeRequest) []string { + start := len(stdoutLines) + require.NoError(t, session.run(context.Background(), req)) + return append([]string(nil), stdoutLines[start:]...) + } + + firstRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: fmt.Sprintf("cd %s\npwd", targetDir), + Hooks: hooks, + Timeout: 3 * time.Second, + }) + require.True(t, containsLine(firstRunLines, targetDir), "expected cd to update cwd to %q, got %v", targetDir, firstRunLines) + + secondRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: "pwd", + Hooks: hooks, + Timeout: 3 * time.Second, + }) + require.True(t, containsLine(secondRunLines, targetDir), "expected subsequent run to inherit cwd %q, got %v", targetDir, secondRunLines) + + session.mu.Lock() + finalCwd := session.cwd + session.mu.Unlock() + require.Equal(t, targetDir, finalCwd, "expected session cwd to stay at %q", targetDir) +} + +func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + initialDir := t.TempDir() + overrideDir := t.TempDir() + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + runAndCollect := func(req *ExecuteCodeRequest) []string { + start := len(stdoutLines) + require.NoError(t, session.run(context.Background(), req)) + return append([]string(nil), stdoutLines[start:]...) + } + + // First request: change session cwd via script. + firstRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: fmt.Sprintf("cd %s\npwd", initialDir), + Hooks: hooks, + Timeout: 3 * time.Second, + }) + require.True(t, containsLine(firstRunLines, initialDir), "expected cd to update cwd to %q, got %v", initialDir, firstRunLines) + + // Second request: explicit Cwd overrides session cwd. + secondRunLines := runAndCollect(&ExecuteCodeRequest{ + Code: "pwd", + Cwd: overrideDir, + Hooks: hooks, + Timeout: 3 * time.Second, + }) + require.True(t, containsLine(secondRunLines, overrideDir), "expected command to run in override cwd %q, got %v", overrideDir, secondRunLines) + + session.mu.Lock() + finalCwd := session.cwd + session.mu.Unlock() + require.Equal(t, overrideDir, finalCwd, "expected session cwd updated to override dir %q", overrideDir) +} + +func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + request := &ExecuteCodeRequest{ + Code: `set +x; printf '{"foo":1}'`, + Hooks: hooks, + Timeout: 3 * time.Second, + } + require.NoError(t, session.run(context.Background(), request)) + + require.Len(t, stdoutLines, 1, "expected exactly one stdout line") + require.Equal(t, `{"foo":1}`, strings.TrimSpace(stdoutLines[0])) + for _, line := range stdoutLines { + require.NotContains(t, line, envDumpStartMarker, "env dump leaked into stdout: %v", stdoutLines) + require.NotContains(t, line, "declare -x", "env dump leaked into stdout: %v", stdoutLines) + } +} + +func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + request := &ExecuteCodeRequest{ + Code: `set +x; true`, + Hooks: hooks, + Timeout: 3 * time.Second, + } + require.NoError(t, session.run(context.Background(), request)) + + require.LessOrEqual(t, len(stdoutLines), 1, "expected at most one stdout line, got %v", stdoutLines) + if len(stdoutLines) == 1 { + require.Empty(t, strings.TrimSpace(stdoutLines[0]), "expected empty stdout") + } + for _, line := range stdoutLines { + require.NotContains(t, line, envDumpStartMarker, "env dump leaked into stdout: %v", stdoutLines) + require.NotContains(t, line, "declare -x", "env dump leaked into stdout: %v", stdoutLines) + } +} + +func TestBashSession_heredoc(t *testing.T) { + rewardDir := t.TempDir() + controller := NewController("", "") + + sessionID, err := controller.CreateBashSession(&CreateContextRequest{}) + require.NoError(t, err) + t.Cleanup(func() { _ = controller.DeleteBashSession(sessionID) }) + + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + fmt.Printf("[stdout] %s\n", line) + }, + OnExecuteComplete: func(d time.Duration) { + fmt.Printf("[complete] %s\n", d) + }, + } + + // First run: heredoc + reward file write. + script := fmt.Sprintf(` +set -x +reward_dir=%q +mkdir -p "$reward_dir" + +cat > /tmp/repro_script.sh <<'SHEOF' +#!/usr/bin/env sh +echo "hello heredoc" +SHEOF + +chmod +x /tmp/repro_script.sh +/tmp/repro_script.sh +echo "after heredoc" +echo 1 > "$reward_dir/reward.txt" +cat "$reward_dir/reward.txt" +`, rewardDir) + + ctx := context.Background() + require.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{ + Context: sessionID, + Language: Bash, + Timeout: 10 * time.Second, + Code: script, + Hooks: hooks, + })) + + // Second run: ensure the session keeps working. + require.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{ + Context: sessionID, + Language: Bash, + Timeout: 5 * time.Second, + Code: "echo 'second command works'", + Hooks: hooks, + })) +} + +func TestBashSession_execReplacesShell(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + script := ` +cat > /tmp/exec_child.sh <<'EOF' +echo "child says hi" +EOF +chmod +x /tmp/exec_child.sh +exec /tmp/exec_child.sh +` + + request := &ExecuteCodeRequest{ + Code: script, + Hooks: hooks, + Timeout: 5 * time.Second, + } + require.NoError(t, session.run(context.Background(), request), "expected exec to complete without killing the session") + require.True(t, containsLine(stdoutLines, "child says hi"), "expected child output, got %v", stdoutLines) + + // Subsequent run should still work because we restart bash per run. + request = &ExecuteCodeRequest{ + Code: "echo still-alive", + Hooks: hooks, + Timeout: 2 * time.Second, + } + stdoutLines = nil + require.NoError(t, session.run(context.Background(), request), "expected run to succeed after exec replaced the shell") + require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) +} + +func TestBashSession_complexExec(t *testing.T) { + session := newBashSession("") + t.Cleanup(func() { _ = session.close() }) + + require.NoError(t, session.start()) + + var stdoutLines []string + hooks := ExecuteResultHook{ + OnExecuteStdout: func(line string) { + stdoutLines = append(stdoutLines, line) + }, + } + + script := ` +LOG_FILE=$(mktemp) +export LOG_FILE +exec 3>&1 4>&2 +exec > >(tee "$LOG_FILE") 2>&1 + +set -x +echo "from-complex-exec" +exec 1>&3 2>&4 # step record +echo "after-restore" +` + + request := &ExecuteCodeRequest{ + Code: script, + Hooks: hooks, + Timeout: 5 * time.Second, + } + require.NoError(t, session.run(context.Background(), request), "expected complex exec to finish") + require.True(t, containsLine(stdoutLines, "from-complex-exec") && containsLine(stdoutLines, "after-restore"), "expected exec outputs, got %v", stdoutLines) + + // Session should still be usable. + request = &ExecuteCodeRequest{ + Code: "echo still-alive", + Hooks: hooks, + Timeout: 2 * time.Second, + } + stdoutLines = nil + require.NoError(t, session.run(context.Background(), request), "expected run to succeed after complex exec") + require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) +} + +func containsLine(lines []string, target string) bool { + for _, l := range lines { + if strings.TrimSpace(l) == target { + return true + } + } + return false +} + +// TestBashSession_CloseKillsRunningProcess verifies that session.close() kills the active +// process group so that a long-running command (e.g. sleep) does not keep running after close. +func TestBashSession_CloseKillsRunningProcess(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + session := newBashSession("") + require.NoError(t, session.start()) + + runDone := make(chan error, 1) + req := &ExecuteCodeRequest{ + Code: "sleep 30", + Timeout: 60 * time.Second, + Hooks: ExecuteResultHook{}, + } + safego.Go(func() { + runDone <- session.run(context.Background(), req) + }) + + // Give the child process time to start. + time.Sleep(200 * time.Millisecond) + + // Close should kill the process group; run() should return soon (it may return nil + // because the code path treats non-zero exit as success after calling OnExecuteError). + require.NoError(t, session.close()) + + select { + case <-runDone: + // run() returned; process was killed so we did not wait 30s + case <-time.After(3 * time.Second): + require.Fail(t, "run did not return within 3s after close (process was not killed)") + } +} + +// TestBashSession_DeleteBashSessionKillsRunningProcess verifies that DeleteBashSession +// (close path) kills the active run and removes the session from the controller. +func TestBashSession_DeleteBashSessionKillsRunningProcess(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + sessionID, err := c.CreateBashSession(&CreateContextRequest{}) + require.NoError(t, err) + + runDone := make(chan error, 1) + req := &ExecuteCodeRequest{ + Language: Bash, + Context: sessionID, + Code: "sleep 30", + Timeout: 60 * time.Second, + Hooks: ExecuteResultHook{}, + } + safego.Go(func() { + runDone <- c.RunInBashSession(context.Background(), req) + }) + + time.Sleep(200 * time.Millisecond) + + require.NoError(t, c.DeleteBashSession(sessionID)) + + select { + case <-runDone: + // RunInBashSession returned; process was killed + case <-time.After(3 * time.Second): + require.Fail(t, "RunInBashSession did not return within 3s after DeleteBashSession") + } + + // Session should be gone; deleting again should return ErrContextNotFound. + err = c.DeleteBashSession(sessionID) + require.Error(t, err) + require.ErrorIs(t, err, ErrContextNotFound) +} + +// TestBashSession_CloseWithNoActiveRun verifies that close() with no running command +// completes without error and does not hang. +func TestBashSession_CloseWithNoActiveRun(t *testing.T) { + session := newBashSession("") + require.NoError(t, session.start()) + + done := make(chan struct{}, 1) + safego.Go(func() { + _ = session.close() + done <- struct{}{} + }) + + select { + case <-done: + // close() returned + case <-time.After(2 * time.Second): + require.Fail(t, "close() did not return within 2s when no run was active") + } +} diff --git a/components/execd/pkg/runtime/bash_session_windows.go b/components/execd/pkg/runtime/bash_session_windows.go new file mode 100644 index 0000000..d0cd008 --- /dev/null +++ b/components/execd/pkg/runtime/bash_session_windows.go @@ -0,0 +1,40 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package runtime + +import ( + "context" + "errors" +) + +var errBashSessionNotSupported = errors.New("bash session is not supported on windows") + +// CreateBashSession is not supported on Windows. +func (c *Controller) CreateBashSession(_ *CreateContextRequest) (string, error) { //nolint:revive + return "", errBashSessionNotSupported +} + +// RunInBashSession is not supported on Windows. +func (c *Controller) RunInBashSession(_ context.Context, _ *ExecuteCodeRequest) error { //nolint:revive + return errBashSessionNotSupported +} + +// DeleteBashSession is not supported on Windows. +func (c *Controller) DeleteBashSession(_ string) error { //nolint:revive + return errBashSessionNotSupported +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_binds_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_binds_test.go new file mode 100644 index 0000000..63428ba --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_binds_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// TestBinds_SourceToDest verifies a host path can be bind-mounted at a distinct +// destination inside the namespace and written through. +func TestBinds_SourceToDest(t *testing.T) { + r := newRunner(t) + + srcDir := t.TempDir() + // Destination must be an existing mount point inside the namespace. Use a + // separate temp dir under /tmp (bind-mounted into the namespace) so bwrap + // can bind onto it without needing to create a dir under a read-only mount. + destDir := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + Binds: []isolation.BindMount{ + {Source: srcDir, Dest: destDir}, + }, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Write via the in-namespace destination path. + err = r.RunInIsolatedSession(ctx, id, "echo 'mapped-data' > "+destDir+"/out.txt", nil, nil) + require.NoError(t, err, "writing to mapped bind dest should succeed") + + // Verify it landed on the host source dir. + data, err := os.ReadFile(filepath.Join(srcDir, "out.txt")) + require.NoError(t, err) + assert.Equal(t, "mapped-data\n", string(data)) +} + +// TestBinds_ReadOnly verifies a read-only bind rejects writes but allows reads. +func TestBinds_ReadOnly(t *testing.T) { + r := newRunner(t) + + srcDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "ro.txt"), []byte("readonly-value\n"), 0o644)) + // Destination is a separate existing mount point under /tmp. + destDir := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + Binds: []isolation.BindMount{ + {Source: srcDir, Dest: destDir, ReadOnly: true}, + }, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Read should succeed. + var lines []string + err = r.RunInIsolatedSession(ctx, id, "cat "+destDir+"/ro.txt", nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"readonly-value"}, lines) + + // Write should fail (non-zero exit). + err = r.RunInIsolatedSession(ctx, id, "echo x > "+destDir+"/new.txt", nil, nil) + require.Error(t, err, "writing to a read-only bind should fail") +} + +// TestBinds_SourceNotInAllowlist verifies binds are rejected when the source +// path is outside the writable allowlist. +func TestBinds_SourceNotInAllowlist(t *testing.T) { + r := newRunnerWithConfig(t, isolation.Config{ + UpperRoot: t.TempDir(), + UpperMaxBytes: 1 << 30, + AllowedWritable: []string{"/tmp/allowed-only"}, + }) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + Binds: []isolation.BindMount{ + {Source: "/etc", Dest: "/mnt/etc"}, + }, + } + + _, err := r.CreateIsolatedSession(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "not in allowlist") +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go new file mode 100644 index 0000000..4873e39 --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go @@ -0,0 +1,394 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// TestStderrIsCaptured verifies that stderr output is captured on stdout +// since cmd.Stderr = cmd.Stdout. +func TestStderrIsCaptured(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // bash sends error messages to stderr. Without Stderr=Stdout, we'd see nothing. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `echo stdout-line && echo stderr-line >&2`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Contains(t, lines, "stdout-line") + assert.Contains(t, lines, "stderr-line") +} + +// TestRecoverAfterFailedRun verifies the session remains usable after a +// command fails (non-zero exit). +func TestRecoverAfterFailedRun(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Run 1: fail with non-zero exit. Use bash -c so the parent bash (the + // persistent session) doesn't exit — exit is a shell builtin. + err = r.RunInIsolatedSession(ctx, id, "bash -c 'exit 42'", nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "42") + + // Run 2: must still work. + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo recovered", nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"recovered"}, lines) + + // Run 3: fail again, then recover again. + err = r.RunInIsolatedSession(ctx, id, "false", nil, nil) + require.Error(t, err) + + lines = nil + err = r.RunInIsolatedSession(ctx, id, "echo alive-again", nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"alive-again"}, lines) +} + +// TestContextCancellation verifies that cancelling the context causes +// RunInIsolatedSession to return promptly. +func TestContextCancellation(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + // Sleep longer than the context deadline so cancellation is tested. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err = r.RunInIsolatedSession(ctx, id, "sleep 10", nil, nil) + assert.Error(t, err, "should return error on context cancellation") + assert.True(t, strings.Contains(err.Error(), "deadline") || strings.Contains(err.Error(), "canceled"), + "error should be context-related, got: %v", err) +} + +// TestContextNotCancelledOnNormalExit verifies context is respected but +// doesn't trigger for fast commands. +func TestContextNotCancelledOnNormalExit(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + // Long timeout, fast command — must not fail. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err = r.RunInIsolatedSession(ctx, id, "echo fast", nil, nil) + require.NoError(t, err) +} + +// TestStrictNetworkIsolation verifies --unshare-net blocks non-loopback +// network access. +func TestStrictNetworkIsolation(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + ShareNet: boolPtr(false), + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // With --unshare-net, only loopback should exist. Any non-loopback + // address should not be reachable. Try to get external connectivity + // info — `ip link show` should show only lo (loopback). + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `ip link show 2>/dev/null | wc -l; echo DONE`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + + // With only loopback, we expect exactly 2 lines: lo + DONE. + // If eth0 existed, we'd see more. + assert.Contains(t, lines, "DONE") + for _, line := range lines { + if line == "DONE" { + continue + } + // The count should be small (only loopback). + t.Logf("ip link count: %s", line) + } +} + +// TestManyConsecutiveRuns runs 100 quick commands to verify the session +// remains stable under load. +func TestManyConsecutiveRuns(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + for i := 0; i < 100; i++ { + err := r.RunInIsolatedSession(ctx, id, "true", nil, nil) + require.NoError(t, err, "run %d failed", i) + } +} + +// TestDeleteThenRecreate verifies a session can be deleted and a new one +// created with the same configuration without resource conflicts. +func TestDeleteThenRecreate(t *testing.T) { + r := newRunner(t) + + ws := t.TempDir() + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: ws, WorkspaceMode: "rw", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for i := 0; i < 3; i++ { + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err, "create %d", i) + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo session-"+string(rune('0'+i)), nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err, "run %d", i) + + err = r.DeleteIsolatedSession(id) + require.NoError(t, err, "delete %d", i) + + // Verify it's gone. + _, err = r.GetIsolatedSession(id) + assert.Error(t, err, "session %d should be gone after delete", i) + } +} + +// TestBashAliasAndBuiltins verifies bash builtins and functions work. +func TestBashBuiltinsAndFunctions(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tests := []struct { + name string + code string + want string + }{ + {"source_define_func", "myfunc() { echo func-called; }; myfunc", "func-called"}, + {"if_statement", `if [ -d /tmp ]; then echo is-dir; fi`, "is-dir"}, + {"for_loop", `for i in 1 2 3; do echo -n "${i} "; done && echo`, "1 2 3 "}, + {"arithmetic", `echo $((3+4))`, "7"}, + {"here_string", `cat <<< "herestring"`, "herestring"}, + {"pipeline_vars", `echo foo | while read x; do echo ${x}; done`, "foo"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var lines []string + err := r.RunInIsolatedSession(ctx, id, tt.code, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + require.NotEmpty(t, lines) + assert.Equal(t, tt.want, lines[0]) + }) + } +} + +// TestUpperQuotaEnforcement writes data past the upper quota limit and +// verifies it is blocked. The upper max is passed separately by the +// UpperManager; for this test we use a small upper root disk and rely +// on the workspace mount semantics. +func TestLargeFileWrite(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Write a moderately large file inside the sandbox. + script := `dd if=/dev/zero of=/tmp/largefile bs=1M count=5 2>&1 && echo "WRITE_OK"` + var lines []string + err = r.RunInIsolatedSession(ctx, id, script, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + + // dd output ends with the summary, then our WRITE_OK. + found := false + for _, l := range lines { + if strings.Contains(l, "WRITE_OK") { + found = true + break + } + } + assert.True(t, found, "large file write should succeed, got: %v", lines) +} + +// TestSessionLeakAfterRun verifies that each Run creates a subprocess but +// does not leak zombie processes in the host. +func TestSessionSubprocessCleanup(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Each run forks a subprocess. Run several short-lived commands. + for i := 0; i < 5; i++ { + require.NoError(t, r.RunInIsolatedSession(ctx, id, "sleep 0.1 && true", nil, nil)) + } + require.NoError(t, r.RunInIsolatedSession(ctx, id, "echo processes: $(ps aux | wc -l)", nil, nil)) +} + +// TestBorderlineBufferSize exercises the scanner buffer with output +// approaching the 64KB default buffer size. +func TestBorderlineBufferSize(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Generate ~60000+ lines to stress the scanner buffer. + script := `for i in $(seq 60000); do echo "line-$i"; done && echo "DONE"` + var lineCount int + err = r.RunInIsolatedSession(ctx, id, script, nil, + func(line string) { + if strings.HasPrefix(line, "line-") { + lineCount++ + } + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, lineCount, 59000, "should capture most lines, got %d", lineCount) +} + +// TestWorkspaceIsolationAcrossSessions verifies two sessions with the same +// workspace path see independent filesystems (in overlay/ro modes) or shared +// changes (in rw mode). +func TestWorkspaceIsolationAcrossSessions(t *testing.T) { + r := newRunner(t) + + ws := t.TempDir() + // Pre-create a file in workspace. + require.NoError(t, os.WriteFile(ws+"/shared.txt", []byte("original"), 0644)) + + // Session 1: rw mode — write modifies workspace directly. + opts1 := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "rw", + } + id1, err := r.CreateIsolatedSession(opts1) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id1) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + require.NoError(t, r.RunInIsolatedSession(ctx, id1, + `echo "modified-by-s1" > `+ws+`/shared.txt`, nil, nil)) + + // Session 2: overlay mode — reads see original workspace content. + opts2 := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "overlay", + } + id2, err := r.CreateIsolatedSession(opts2) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id2) + + var lines []string + require.NoError(t, r.RunInIsolatedSession(ctx, id2, + `cat `+ws+`/shared.txt`, nil, + func(line string) { lines = append(lines, line) })) + // overlay mode: should see the original or modified content depending + // on whether the modification was to the workspace (it was) and whether + // overlay sees the lower layer correctly. + assert.Contains(t, lines, "modified-by-s1", + "overlay mode should see workspace changes (lower layer)") +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_error_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_error_test.go new file mode 100644 index 0000000..6642af7 --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_error_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func TestCreateSession_EmptyWorkspace(t *testing.T) { + r := newRunner(t) + _, err := r.CreateIsolatedSession(&runtime.IsolatedSessionOptions{}) + assert.Error(t, err, "should reject empty workspace path") +} + +func TestGetSession_NotFound(t *testing.T) { + r := newRunner(t) + _, err := r.GetIsolatedSession("nonexistent-session-id") + assert.Error(t, err) +} + +func TestDeleteSession_NotFound(t *testing.T) { + r := newRunner(t) + err := r.DeleteIsolatedSession("nonexistent-session-id") + assert.Error(t, err) +} + +func TestRun_SessionNotFound(t *testing.T) { + r := newRunner(t) + err := r.RunInIsolatedSession(context.Background(), "nonexistent", "true", nil, nil) + assert.Error(t, err) +} + +func TestGetSession_AfterDelete(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + require.NoError(t, r.DeleteIsolatedSession(id)) + + _, err = r.GetIsolatedSession(id) + assert.Error(t, err, "session should not exist after delete") +} + +func TestRun_AfterDelete(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + require.NoError(t, r.DeleteIsolatedSession(id)) + + err = r.RunInIsolatedSession(context.Background(), id, "true", nil, nil) + assert.Error(t, err, "should not be able to run on deleted session") +} + +func TestRun_Timeout(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + // Note: context timeout propagation to bwrap process is not yet + // implemented. Verify that the command completes and returns. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = r.RunInIsolatedSession(ctx, id, "sleep 1", nil, nil) + require.NoError(t, err, "sleep 1 should complete within timeout") +} + +func TestDoubleDelete(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + require.NoError(t, r.DeleteIsolatedSession(id)) + assert.Error(t, r.DeleteIsolatedSession(id), "second delete should fail") +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go new file mode 100644 index 0000000..fdff92f --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// TestExtraWritable_WriteFile verifies that an ExtraWritable path can be +// written to via stdin pipe file redirection. +func TestExtraWritable_WriteFile(t *testing.T) { + r := newRunner(t) + + extraDir := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + ExtraWritable: []string{extraDir}, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + testFile := filepath.Join(extraDir, "written-from-sandbox.txt") + code := "echo 'extra-writable-data' > " + testFile + err = r.RunInIsolatedSession(ctx, id, code, nil, nil) + require.NoError(t, err, "writing to ExtraWritable path should succeed") + + // Verify the file exists on the host (since ExtraWritable is bind-mounted). + data, err := os.ReadFile(testFile) + require.NoError(t, err) + assert.Equal(t, "extra-writable-data\n", string(data)) +} + +// TestExtraWritable_ReadWriteRoundTrip writes then reads back a file on an +// ExtraWritable path via the stdin pipe. +func TestExtraWritable_ReadWriteRoundTrip(t *testing.T) { + r := newRunner(t) + + extraDir := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + ExtraWritable: []string{extraDir}, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + testFile := filepath.Join(extraDir, "roundtrip.txt") + + // Write. + err = r.RunInIsolatedSession(ctx, id, "echo 'roundtrip-value' > "+testFile, nil, nil) + require.NoError(t, err) + + // Read back. + var lines []string + err = r.RunInIsolatedSession(ctx, id, "cat "+testFile, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"roundtrip-value"}, lines) +} + +// TestExtraWritable_MultipleWrites writes to multiple ExtraWritable paths and +// verifies isolation between sessions. +func TestExtraWritable_MultipleWrites(t *testing.T) { + r := newRunner(t) + + dir1 := t.TempDir() + dir2 := t.TempDir() + + opts1 := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + ExtraWritable: []string{dir1}, + } + opts2 := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + ExtraWritable: []string{dir2}, + } + + id1, err := r.CreateIsolatedSession(opts1) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id1) + + id2, err := r.CreateIsolatedSession(opts2) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id2) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Session 1 writes to dir1, must not appear in dir2. + require.NoError(t, r.RunInIsolatedSession(ctx, id1, + "echo 's1-data' > "+filepath.Join(dir1, "s1.txt"), nil, nil)) + + // Session 2 writes to dir2, must not appear in dir1. + require.NoError(t, r.RunInIsolatedSession(ctx, id2, + "echo 's2-data' > "+filepath.Join(dir2, "s2.txt"), nil, nil)) + + // Verify host visibility. + data, err := os.ReadFile(filepath.Join(dir1, "s1.txt")) + require.NoError(t, err) + assert.Equal(t, "s1-data\n", string(data)) + + data, err = os.ReadFile(filepath.Join(dir2, "s2.txt")) + require.NoError(t, err) + assert.Equal(t, "s2-data\n", string(data)) +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_filesystem_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_filesystem_test.go new file mode 100644 index 0000000..4000d49 --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_filesystem_test.go @@ -0,0 +1,339 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func TestFilesystem_ReadLower(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-lower" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "readme.txt"), []byte("hello-lower"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Read file from lower (workspace). + data, err := mv.ReadFile("readme.txt") + require.NoError(t, err) + assert.Equal(t, []byte("hello-lower"), data) + + // Stat the file. + info, err := mv.Stat("readme.txt") + require.NoError(t, err) + assert.Equal(t, "readme.txt", info.Name()) +} + +func TestFilesystem_WriteUpper(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-write" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Write a file via MergedView. + require.NoError(t, mv.WriteFile("session-file.txt", []byte("from-session"), 0o644)) + + // In rw mode, write goes directly to workspace (host view). + data, err := os.ReadFile(filepath.Join(wsDir, "session-file.txt")) + require.NoError(t, err) + assert.Equal(t, []byte("from-session"), data) +} + +func TestFilesystem_WriteThenRead(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-rw" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + content := []byte("write-then-read") + require.NoError(t, mv.WriteFile("data.txt", content, 0o644)) + + data, err := mv.ReadFile("data.txt") + require.NoError(t, err) + assert.Equal(t, content, data) +} + +func TestFilesystem_Delete(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-del" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Write then delete. + require.NoError(t, mv.WriteFile("del.txt", []byte("tmp"), 0o644)) + require.NoError(t, mv.Remove("del.txt")) + + _, err = mv.Stat("del.txt") + assert.True(t, os.IsNotExist(err)) +} + +func TestFilesystem_MkdirAndList(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-dir" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + require.NoError(t, mv.MkdirAll("a/b", 0o755)) + require.NoError(t, mv.WriteFile("a/b/f.txt", []byte("nested"), 0o644)) + + entries, err := mv.ReadDir("a/b") + require.NoError(t, err) + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + assert.Contains(t, names, "f.txt") +} + +func TestFilesystem_Rename(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-rename" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + require.NoError(t, mv.WriteFile("old.txt", []byte("renamed"), 0o644)) + require.NoError(t, mv.Rename("old.txt", "new.txt")) + + _, err = mv.Stat("old.txt") + assert.True(t, os.IsNotExist(err)) + + data, err := mv.ReadFile("new.txt") + require.NoError(t, err) + assert.Equal(t, []byte("renamed"), data) +} + +func TestFilesystem_Chmod(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-chmod" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + require.NoError(t, mv.WriteFile("perm.txt", []byte("x"), 0o600)) + require.NoError(t, mv.Chmod("perm.txt", 0o755)) + + info, err := mv.Stat("perm.txt") + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o755), info.Mode().Perm()) +} + +func TestFilesystem_ReplaceContent(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-replace" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + require.NoError(t, mv.WriteFile("text.txt", []byte("abc def abc"), 0o644)) + require.NoError(t, mv.ReplaceContent("text.txt", "abc", "xyz")) + + data, err := mv.ReadFile("text.txt") + require.NoError(t, err) + assert.Equal(t, []byte("xyz def xyz"), data) +} + +func TestFilesystem_Search(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-search" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + require.NoError(t, mv.WriteFile("a.txt", []byte("a"), 0o644)) + require.NoError(t, mv.WriteFile("b.log", []byte("b"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "c.txt"), []byte("c"), 0o644)) + + results, err := mv.Search(".", "*.txt") + require.NoError(t, err) + assert.Len(t, results, 2) + assert.Contains(t, results, "a.txt") + assert.Contains(t, results, "c.txt") +} + +func TestFilesystem_OverlayWriteNotVisibleOnHost(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-overlay" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "lower.txt"), []byte("lower"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "overlay", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Write goes to upper, not lower. + require.NoError(t, mv.WriteFile("upper-only.txt", []byte("secret"), 0o644)) + + // NOT visible on host workspace. + _, err = os.ReadFile(filepath.Join(wsDir, "upper-only.txt")) + assert.True(t, os.IsNotExist(err)) + + // Visible via MergedView. + data, err := mv.ReadFile("upper-only.txt") + require.NoError(t, err) + assert.Equal(t, []byte("secret"), data) + + // Lower file still visible. + data, err = mv.ReadFile("lower.txt") + require.NoError(t, err) + assert.Equal(t, []byte("lower"), data) +} + +func TestFilesystem_ReadOnly(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-fs-ro" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "readme.txt"), []byte("ro-data"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "ro", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Read works. + data, err := mv.ReadFile("readme.txt") + require.NoError(t, err) + assert.Equal(t, []byte("ro-data"), data) + + // Write denied. + assert.Error(t, mv.WriteFile("new.txt", []byte("x"), 0o644)) + assert.Error(t, mv.Remove("readme.txt")) + assert.Error(t, mv.MkdirAll("d", 0o755)) +} + +func TestFilesystem_GetMergedView_NotFound(t *testing.T) { + r := newRunner(t) + _, err := r.GetMergedView("nonexistent") + assert.Error(t, err) +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_isolation_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_isolation_test.go new file mode 100644 index 0000000..d2d59fa --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_isolation_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func TestEnvBlacklist(t *testing.T) { + r := newRunner(t) + + os.Setenv("BWRAP_TEST_SECRET_TOKEN", "super-secret-12345") + defer os.Unsetenv("BWRAP_TEST_SECRET_TOKEN") + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + EnvPassthroughMode: "deny", + EnvPassthroughKeys: nil, // triggers built-in strict blacklist (*_TOKEN etc.) + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo TOKEN=${BWRAP_TEST_SECRET_TOKEN:-NOT_SET}", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + assert.Contains(t, lines[0], "NOT_SET", "secret token should have been stripped") +} + +func TestEnvAllowMode(t *testing.T) { + r := newRunner(t) + + os.Setenv("BWRAP_ALLOWED_VAR", "allowed-value") + os.Setenv("BWRAP_BLOCKED_VAR", "blocked-value") + defer os.Unsetenv("BWRAP_ALLOWED_VAR") + defer os.Unsetenv("BWRAP_BLOCKED_VAR") + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + EnvPassthroughMode: "allow", + EnvPassthroughKeys: []string{"BWRAP_ALLOWED_VAR"}, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo ALLOWED=${BWRAP_ALLOWED_VAR:-MISSING} BLOCKED=${BWRAP_BLOCKED_VAR:-MISSING}", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + assert.Contains(t, lines[0], "ALLOWED=allowed-value", "allowlisted var should be present") + assert.Contains(t, lines[0], "BLOCKED=MISSING", "non-allowlisted var should be absent") +} + +func TestNetworkIsolation(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + ShareNet: boolPtr(false), + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // With --unshare-net, only loopback should be visible. + var lines []string + err = r.RunInIsolatedSession(ctx, id, "ip addr show 2>/dev/null | grep -c 'LOOPBACK' || echo lo_visible", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + require.NotEmpty(t, lines) + t.Logf("network test output: %v", lines) +} + +func TestCustomUID(t *testing.T) { + r := newRunner(t) + + uid := uint32(1000) + gid := uint32(1000) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + Uid: &uid, + Gid: &gid, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "id -u; id -g", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + require.Len(t, lines, 2) + assert.Equal(t, "1000", lines[0], "uid should be 1000") + assert.Equal(t, "1000", lines[1], "gid should be 1000") +} + +func TestBalancedProfile(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Balanced shares host /tmp. + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo balanced-test > /tmp/bwrap-balanced-test.txt", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + + data, err := os.ReadFile("/tmp/bwrap-balanced-test.txt") + require.NoError(t, err, "balanced profile: /tmp should be shared with host") + assert.Equal(t, "balanced-test\n", string(data)) + os.Remove("/tmp/bwrap-balanced-test.txt") +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_seccomp_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_seccomp_test.go new file mode 100644 index 0000000..71942b9 --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_seccomp_test.go @@ -0,0 +1,295 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func TestSeccompFilterActive(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Check /proc/self/status for seccomp mode. Mode 2 = SECCOMP_MODE_FILTER. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `grep Seccomp: /proc/self/status`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + require.NotEmpty(t, lines, "should have Seccomp line in /proc/self/status") + + t.Logf("Seccomp status: %s", lines[0]) + assert.Contains(t, strings.TrimSpace(lines[0]), "2", + "Seccomp should be mode 2 (SECCOMP_MODE_FILTER)") +} + +func TestSeccompAllowsNormalSyscalls(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Common operations that exercise various syscalls: open, read, write, + // fork, exec, waitpid, stat, getdents, etc. All must work. + tests := []struct { + name string + code string + want string + }{ + {"echo", `echo hello-seccomp`, "hello-seccomp"}, + {"stat", `stat -c %s /proc/self/exe`, ""}, // any non-zero output is fine + {"readdir", `ls /proc/self >/dev/null && echo ok`, "ok"}, // getdents + {"fork_exec", `sh -c 'echo child-ok'`, "child-ok"}, + {"pipe", `echo pipe-test | cat`, "pipe-test"}, + {"env", `export FOO=bar && echo $FOO`, "bar"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var lines []string + err := r.RunInIsolatedSession(ctx, id, tt.code, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err, "command should succeed: %s", tt.code) + if tt.want != "" { + require.NotEmpty(t, lines) + assert.Equal(t, tt.want, lines[0]) + } + }) + } +} + +func TestSeccompBlocksPtrace(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Attempt to ptrace-attach to init (PID 1 in the bwrap namespace). + // This should fail with EACCES from seccomp. + // Use bash -c to capture exit code — strace/ptrace isn't a bash builtin, + // but we can use Python if available, or check the exit code of a + // command that uses ptrace. + // + // strace is the easiest way: strace -p 1 should fail before even starting. + // If strace isn't installed, we skip with a clear message. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `if command -v strace >/dev/null 2>&1; then strace -p 1 2>&1 | head -1; else echo 'strace-not-installed'; fi`, nil, + func(line string) { lines = append(lines, line) }) + + if len(lines) > 0 && lines[0] == "strace-not-installed" { + t.Skip("strace not available in sandbox") + } + + // If strace is available, the seccomp filter blocks ptrace and strace + // should report "Permission denied" (EACCES) rather than "Operation + // not permitted" (EPERM from capabilities). + require.NoError(t, err) + t.Logf("strace/ptrace result: %v", lines) + if len(lines) > 0 { + combined := strings.Join(lines, " ") + assert.True(t, + strings.Contains(strings.ToLower(combined), "permission denied") || + strings.Contains(strings.ToLower(combined), "operation not permitted"), + "ptrace should be blocked, got: %s", combined) + } +} + +func TestSeccompBlocksMount(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Attempt a real mount syscall. bwrap namespace init (PID 1) has full + // capabilities inside the non-user namespace, so mount would succeed + // if seccomp didn't block it at the syscall level with EACCES. + err = r.RunInIsolatedSession(ctx, id, + `mkdir -p /tmp/mnt && mount -t tmpfs tmpfs /tmp/mnt 2>&1`, nil, + nil) + require.Error(t, err, "mount should be blocked by seccomp") + t.Logf("mount error: %v", err) +} + +func TestSeccompPersistsAcrossRuns(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Run 1: check seccomp is active. + var lines []string + require.NoError(t, r.RunInIsolatedSession(ctx, id, + `grep Seccomp: /proc/self/status`, nil, + func(line string) { lines = append(lines, line) })) + require.NotEmpty(t, lines) + assert.Contains(t, strings.TrimSpace(lines[0]), "2", "first run: seccomp should be mode 2") + + // Run 2: seccomp should still be active (not a one-shot). + lines = nil + require.NoError(t, r.RunInIsolatedSession(ctx, id, + `grep Seccomp: /proc/self/status`, nil, + func(line string) { lines = append(lines, line) })) + require.NotEmpty(t, lines) + assert.Contains(t, strings.TrimSpace(lines[0]), "2", "second run: seccomp should still be mode 2") +} + +// TestSeccompConfigOverride_E2E verifies the full path: +// TOML config → LoadConfig → NewBwrap(cfg) → generateSeccompDenyBPF → bwrap session. +// Uses a custom denylist that blocks "unshare" but does NOT block "mount", +// then verifies mount succeeds (it would fail with the default denylist). +func TestSeccompConfigOverride_E2E(t *testing.T) { + // Write a TOML config with a custom seccomp denylist. + cfgPath := filepath.Join(t.TempDir(), "isolation.toml") + tomlContent := ` +upper_root = "` + t.TempDir() + `" +upper_max_bytes = 1073741824 +allowed_writable = ["/tmp"] + +[seccomp] +# Only block unshare — notably does NOT block mount. +deny = ["unshare"] +` + require.NoError(t, os.WriteFile(cfgPath, []byte(tomlContent), 0o644)) + + cfg, err := isolation.LoadConfig(cfgPath) + require.NoError(t, err) + require.NotNil(t, cfg.Seccomp, "config should have seccomp override") + assert.Equal(t, []string{"unshare"}, cfg.Seccomp.Deny) + + r := newRunnerWithConfig(t, cfg) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Seccomp should still be active (mode 2). + var lines []string + require.NoError(t, r.RunInIsolatedSession(ctx, id, + `grep Seccomp: /proc/self/status`, nil, + func(line string) { lines = append(lines, line) })) + require.NotEmpty(t, lines) + assert.Contains(t, strings.TrimSpace(lines[0]), "2") + + // mount should succeed — it's NOT in our custom denylist. + err = r.RunInIsolatedSession(ctx, id, + `mkdir -p /tmp/test-mnt && mount -t tmpfs tmpfs /tmp/test-mnt && echo mount-ok && umount /tmp/test-mnt`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err, "mount should succeed with custom seccomp that doesn't block it") +} + +// TestSeccompConfigDefault_E2E verifies that nil Seccomp (no [seccomp] +// section) uses the built-in denylist — mount is blocked. +func TestSeccompConfigDefault_E2E(t *testing.T) { + cfg := isolation.Config{ + UpperRoot: t.TempDir(), + UpperMaxBytes: 1 << 30, + AllowedWritable: []string{"/tmp"}, + Seccomp: nil, // use built-in denylist + } + + r := newRunnerWithConfig(t, cfg) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // mount should fail — blocked by built-in seccomp denylist. + err = r.RunInIsolatedSession(ctx, id, + `mkdir -p /tmp/mnt2 && mount -t tmpfs tmpfs /tmp/mnt2 2>&1`, nil, + nil) + require.Error(t, err, "mount should be blocked by default seccomp denylist") +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_smoke_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_smoke_test.go new file mode 100644 index 0000000..bebd0b5 --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_smoke_test.go @@ -0,0 +1,329 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func TestPIDIsolation(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", WorkspacePath: t.TempDir(), WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo $$", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + require.NotEmpty(t, lines) + assert.True(t, len(lines[0]) <= 2, "PID in new namespace should be small, got: %s", lines[0]) +} + +func TestEchoOutput(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo hello-world-from-bwrap", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + assert.Equal(t, []string{"hello-world-from-bwrap"}, lines) +} + +func TestEnvPersistence(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + require.NoError(t, r.RunInIsolatedSession(ctx, id, "export MY_VAR=persisted_42", nil, nil)) + + var lines []string + require.NoError(t, r.RunInIsolatedSession(ctx, id, "echo $MY_VAR", nil, func(line string) { + lines = append(lines, line) + })) + assert.Equal(t, []string{"persisted_42"}, lines) +} + +func TestNonZeroExit(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err = r.RunInIsolatedSession(ctx, id, "bash -c 'exit 13'", nil, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "13") +} + +func TestMultipleRuns(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for i := 0; i < 10; i++ { + expected := fmt.Sprintf("run-%d", i) + var lines []string + require.NoError(t, r.RunInIsolatedSession(ctx, id, "echo "+expected, nil, func(line string) { + lines = append(lines, line) + }), "run %d", i) + assert.Equal(t, []string{expected}, lines, "run %d output", i) + } +} + +func TestStdinStreaming(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + script := `for i in $(seq 1 50); do echo "line-$i"; done` + var lines []string + require.NoError(t, r.RunInIsolatedSession(ctx, id, script, nil, func(line string) { + lines = append(lines, line) + })) + + assert.Len(t, lines, 50) + for i := 1; i <= 50; i++ { + assert.Equal(t, fmt.Sprintf("line-%d", i), lines[i-1], "line %d", i) + } +} + +func TestLargeOutput(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + script := `for i in $(seq 1 1000); do echo "line-$i"; done` + var lines []string + err = r.RunInIsolatedSession(ctx, id, script, nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + assert.Len(t, lines, 1000) + assert.Equal(t, "line-1", lines[0]) + assert.Equal(t, "line-1000", lines[999]) +} + +func TestEmptyCode(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err = r.RunInIsolatedSession(ctx, id, "", nil, nil) + require.NoError(t, err, "empty code should not error") +} + +func TestSessionState(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + state, err := r.GetIsolatedSession(id) + require.NoError(t, err) + assert.Equal(t, "active", state.Status) + assert.False(t, state.CreatedAt.IsZero()) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, r.RunInIsolatedSession(ctx, id, "true", nil, nil)) + + state2, err := r.GetIsolatedSession(id) + require.NoError(t, err) + assert.True(t, !state2.LastRunAt.Before(state.LastRunAt), "lastRunAt should advance after run") +} + +func TestCapabilities(t *testing.T) { + r := newRunner(t) + caps := r.Capabilities() + assert.True(t, caps.Available) + assert.Equal(t, "bwrap", caps.Isolator) + assert.False(t, caps.CommitSupported) + assert.False(t, caps.DiffSupported) + // Version may be empty on older bwrap or different output formats. + if caps.Version != "" { + t.Logf("bwrap version: %s", caps.Version) + } +} + +func TestSessionCleanup(t *testing.T) { + r := newRunner(t) + opts := &runtime.IsolatedSessionOptions{WorkspacePath: t.TempDir(), WorkspaceMode: "rw"} + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = r.RunInIsolatedSession(ctx, id, "true", nil, nil) + + require.NoError(t, r.DeleteIsolatedSession(id)) + time.Sleep(200 * time.Millisecond) + + _, err = r.GetIsolatedSession(id) + assert.Error(t, err) +} + +func TestIdleGC(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + IdleTimeoutSeconds: 2, + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + + // Run a command to set lastRunAt. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, r.RunInIsolatedSession(ctx, id, "true", nil, nil)) + + // Session should exist immediately after run. + _, err = r.GetIsolatedSession(id) + require.NoError(t, err) + + // Wait for GC to collect it (2s timeout + 60s GC interval... + // GC interval is 60s, too slow for test. Manually trigger. + r.CollectIdle() + + // After GC, session should be gone (idle > 2s since we waited). + // But since lastRunAt was just updated, it should still exist. + _, err = r.GetIsolatedSession(id) + require.NoError(t, err) + + // Wait past the idle timeout. + time.Sleep(3 * time.Second) + + // Trigger GC again — now it should be collected. + r.CollectIdle() + + _, err = r.GetIsolatedSession(id) + assert.Error(t, err, "session should be deleted by GC after idle timeout") +} + +func TestIdleGC_Disabled(t *testing.T) { + r := newRunner(t) + + opts := &runtime.IsolatedSessionOptions{ + WorkspacePath: t.TempDir(), + WorkspaceMode: "rw", + IdleTimeoutSeconds: 0, // disabled + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + // CollectIdle should not delete sessions with timeout=0. + r.CollectIdle() + + _, err = r.GetIsolatedSession(id) + require.NoError(t, err, "session should still exist when GC disabled") +} + +func TestTmpIsolation(t *testing.T) { + r := newRunner(t) + wsDir := t.TempDir() + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + require.NoError(t, r.RunInIsolatedSession(ctx, id, "echo secret > /tmp/bwrap-test-file.txt", nil, nil)) + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "cat /tmp/bwrap-test-file.txt", nil, func(line string) { + lines = append(lines, line) + }) + require.NoError(t, err) + assert.Equal(t, "secret", strings.TrimSpace(lines[0])) + + _, err = os.Stat("/tmp/bwrap-test-file.txt") + assert.True(t, os.IsNotExist(err), "tmpfs leak: file should not exist on host /tmp") +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_workflow_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_workflow_test.go new file mode 100644 index 0000000..2173aee --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_workflow_test.go @@ -0,0 +1,294 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// TestWorkflow_RunFileRunFile exercises the full lifecycle: +// +// Run (generate file) → API read → API write → Run (read API-written file) +func TestWorkflow_RunFileRunFile(t *testing.T) { + r := newRunner(t) + ws := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Step 1: Run generates a file in workspace. + err = r.RunInIsolatedSession(ctx, id, + `echo "generated-by-run" > `+ws+`/step1.txt`, nil, nil) + require.NoError(t, err) + + // Step 2: API reads the file Run just created. + data, err := mv.ReadFile("step1.txt") + require.NoError(t, err) + assert.Equal(t, "generated-by-run\n", string(data)) + + // Step 3: API writes a new file into workspace. + require.NoError(t, mv.WriteFile("step3.txt", []byte("written-by-api"), 0o644)) + + // Step 4: Run reads the file API just wrote. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `cat `+ws+`/step3.txt`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + require.Len(t, lines, 1) + assert.Equal(t, "written-by-api", lines[0]) +} + +// TestWorkflow_RunModifyRunVerify does: +// +// Run (create file) → API modify (replace content) → Run (verify modification) +func TestWorkflow_RunModifyRunVerify(t *testing.T) { + r := newRunner(t) + ws := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Run: create a config file. + err = r.RunInIsolatedSession(ctx, id, + `echo 'host=localhost' > `+ws+`/config.txt && echo 'port=8080' >> `+ws+`/config.txt`, nil, nil) + require.NoError(t, err) + + // API: replace host value. + require.NoError(t, mv.ReplaceContent("config.txt", "localhost", "10.0.0.1")) + + // Run: verify the replacement took effect. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `grep host `+ws+`/config.txt`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + require.NotEmpty(t, lines) + assert.Equal(t, "host=10.0.0.1", lines[0]) +} + +// TestWorkflow_MultiStepBuild simulates a multi-step build workflow: +// +// API write source → Run compile → API read output → Run cleanup → API verify clean +func TestWorkflow_MultiStepBuild(t *testing.T) { + r := newRunner(t) + ws := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Step 1: API writes a "source file". + source := `#!/bin/sh +echo "build output: $(date +%s)" +` + require.NoError(t, mv.WriteFile("build.sh", []byte(source), 0o755)) + + // Step 2: Run executes the build script, captures output to a file. + err = r.RunInIsolatedSession(ctx, id, + `sh `+ws+`/build.sh > `+ws+`/output.txt`, nil, nil) + require.NoError(t, err) + + // Step 3: API reads the build output. + data, err := mv.ReadFile("output.txt") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(string(data), "build output: ")) + + // Step 4: Run cleans up. + err = r.RunInIsolatedSession(ctx, id, + `rm `+ws+`/output.txt`, nil, nil) + require.NoError(t, err) + + // Step 5: API verifies cleanup. + _, err = mv.Stat("output.txt") + assert.True(t, os.IsNotExist(err)) +} + +// TestWorkflow_OverlayRunWriteAPIRead tests overlay mode: +// Run writes inside namespace (goes to upper) → API reads via MergedView +// → verify lower (host workspace) stays clean. +// +// Note: API writes to upper dir on host are NOT visible inside bwrap's +// live overlayfs mount (kernel VFS cache doesn't see direct upper changes). +// So we only test Run→API direction, not API→Run. +func TestWorkflow_OverlayRunWriteAPIRead(t *testing.T) { + r := newRunner(t) + ws := t.TempDir() + + require.NoError(t, os.WriteFile(filepath.Join(ws, "seed.txt"), []byte("original"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "overlay", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Step 1: Run reads seed file from lower layer. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `cat `+ws+`/seed.txt`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + require.Len(t, lines, 1) + assert.Equal(t, "original", lines[0]) + + // Step 2: Run writes a new file inside namespace (goes to upper). + err = r.RunInIsolatedSession(ctx, id, + `echo "from-run" > `+ws+`/run-file.txt`, nil, nil) + require.NoError(t, err) + + // Step 3: API reads the Run-written file via MergedView. + data, err := mv.ReadFile("run-file.txt") + require.NoError(t, err) + assert.Equal(t, "from-run\n", string(data)) + + // Step 4: Verify lower (host workspace) is untouched. + _, err = os.Stat(filepath.Join(ws, "run-file.txt")) + assert.True(t, os.IsNotExist(err), "overlay write should not touch host workspace") + + data, err = os.ReadFile(filepath.Join(ws, "seed.txt")) + require.NoError(t, err) + assert.Equal(t, "original", string(data)) +} + +// TestWorkflow_EnvAndFileInteraction tests env vars set by Run persist +// across file operations. +func TestWorkflow_EnvAndFileInteraction(t *testing.T) { + r := newRunner(t) + ws := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // Run 1: set env var. + err = r.RunInIsolatedSession(ctx, id, `export APP_VERSION=1.2.3`, nil, nil) + require.NoError(t, err) + + // API: write a template file. + require.NoError(t, mv.WriteFile("version.txt", []byte("VERSION=__PLACEHOLDER__"), 0o644)) + + // Run 2: use env var to fill the template, write result. + err = r.RunInIsolatedSession(ctx, id, + `sed "s/__PLACEHOLDER__/$APP_VERSION/" `+ws+`/version.txt > `+ws+`/version_final.txt`, nil, nil) + require.NoError(t, err) + + // API: read the final file. + data, err := mv.ReadFile("version_final.txt") + require.NoError(t, err) + assert.Equal(t, "VERSION=1.2.3", strings.TrimSpace(string(data))) +} + +// TestWorkflow_MkdirUploadRunProcess tests: +// +// API mkdir → API upload files → Run process all files → API read results +func TestWorkflow_MkdirUploadRunProcess(t *testing.T) { + r := newRunner(t) + ws := t.TempDir() + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: ws, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + mv, err := r.GetMergedView(id) + require.NoError(t, err) + + // API: create input directory and upload data files. + require.NoError(t, mv.MkdirAll("input", 0o755)) + require.NoError(t, mv.WriteFile("input/a.csv", []byte("1,2,3\n4,5,6\n"), 0o644)) + require.NoError(t, mv.WriteFile("input/b.csv", []byte("7,8,9\n"), 0o644)) + + // Run: count total lines across all CSV files, write result. + var lines []string + err = r.RunInIsolatedSession(ctx, id, + `wc -l `+ws+`/input/*.csv | tail -1`, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + require.NotEmpty(t, lines) + assert.Contains(t, lines[0], "3", "should count 3 total lines (2+1)") + + // Run: concatenate all CSVs into output. + err = r.RunInIsolatedSession(ctx, id, + `cat `+ws+`/input/*.csv > `+ws+`/merged.csv`, nil, nil) + require.NoError(t, err) + + // API: read merged result. + data, err := mv.ReadFile("merged.csv") + require.NoError(t, err) + assert.Equal(t, "1,2,3\n4,5,6\n7,8,9\n", string(data)) +} diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_workspace_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_workspace_test.go new file mode 100644 index 0000000..d8391a5 --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_workspace_test.go @@ -0,0 +1,141 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func TestWorkspaceRW(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-rw" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "original.txt"), []byte("ok"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo hello && echo world", nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"hello", "world"}, lines) +} + +func TestWorkspaceReadOnly(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-ro" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "readme.txt"), []byte("readonly-data"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "ro", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var lines []string + err = r.RunInIsolatedSession(ctx, id, "echo hello-ro", nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"hello-ro"}, lines) +} + +func TestWorkspaceOverlay(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-overlay" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + require.NoError(t, os.WriteFile(filepath.Join(wsDir, "original.txt"), []byte("original"), 0o644)) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "balanced", WorkspacePath: wsDir, WorkspaceMode: "overlay", + } + id, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Read original + write new file in single command via overlay. + newFile := filepath.Join(wsDir, "upper-only.txt") + var lines []string + err = r.RunInIsolatedSession(ctx, id, + "echo overlay-read && echo upper-data > "+newFile, nil, + func(line string) { lines = append(lines, line) }) + require.NoError(t, err) + assert.Equal(t, []string{"overlay-read"}, lines) + + // Overlay writes go to upper, must NOT leak to host workspace. + _, err = os.ReadFile(newFile) + assert.True(t, os.IsNotExist(err), "overlay mode: file should NOT leak to host workspace") +} + +func TestConcurrentSessions(t *testing.T) { + r := newRunner(t) + + wsDir := "/tmp/bwrap-test-conc" + require.NoError(t, os.MkdirAll(wsDir, 0o755)) + defer os.RemoveAll(wsDir) + + opts := &runtime.IsolatedSessionOptions{ + Profile: "strict", WorkspacePath: wsDir, WorkspaceMode: "rw", + } + + id1, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id1) + + id2, err := r.CreateIsolatedSession(opts) + require.NoError(t, err) + defer r.DeleteIsolatedSession(id2) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var out1, out2 []string + require.NoError(t, r.RunInIsolatedSession(ctx, id1, "echo one", nil, func(l string) { out1 = append(out1, l) })) + require.NoError(t, r.RunInIsolatedSession(ctx, id2, "echo two", nil, func(l string) { out2 = append(out2, l) })) + assert.Equal(t, "one", out1[0]) + assert.Equal(t, "two", out2[0]) +} diff --git a/components/execd/pkg/runtime/bwrap_test/helper_test.go b/components/execd/pkg/runtime/bwrap_test/helper_test.go new file mode 100644 index 0000000..95638ea --- /dev/null +++ b/components/execd/pkg/runtime/bwrap_test/helper_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux && bwrap + +package bwrap_test + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +func boolPtr(b bool) *bool { return &b } + +func newRunner(t *testing.T) *runtime.IsolatedRunner { + t.Helper() + return newRunnerWithConfig(t, isolation.Config{ + UpperRoot: t.TempDir(), + UpperMaxBytes: 1 << 30, + AllowedWritable: []string{"/tmp"}, + }) +} + +func newRunnerWithConfig(t *testing.T, cfg isolation.Config) *runtime.IsolatedRunner { + t.Helper() + + ctrl := runtime.NewController("", "") + iso := isolation.NewBwrap(cfg) + if !iso.Available() { + t.Skip("bwrap not available") + } + + r, err := runtime.NewIsolatedRunner(ctrl, iso, cfg) + require.NoError(t, err) + return r +} + +func TestMain(m *testing.M) { + if os.Getenv("SKIP_INTEGRATION") != "" { + os.Exit(0) + } + + cmd := exec.Command("bwrap", "--version") + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "bwrap not available, skipping: %v\n", err) + os.Exit(0) + } + + if os.Getuid() != 0 { + fmt.Fprintf(os.Stderr, "requires root (use: sudo go test -tags=linux,bwrap ./pkg/runtime/bwrap_test/)\n") + os.Exit(0) + } + + os.Exit(m.Run()) +} diff --git a/components/execd/pkg/runtime/command.go b/components/execd/pkg/runtime/command.go new file mode 100644 index 0000000..db4abcf --- /dev/null +++ b/components/execd/pkg/runtime/command.go @@ -0,0 +1,361 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "os/user" + "strconv" + "sync" + "syscall" + "time" + + "github.com/alibaba/opensandbox/internal/safego" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" +) + +var forwardSignals = []os.Signal{ + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGHUP, + syscall.SIGQUIT, + syscall.SIGUSR1, + syscall.SIGUSR2, + syscall.SIGWINCH, +} + +// getShell returns the preferred shell, falling back to sh if bash is not available. +// This is needed for Alpine-based Docker images that only have sh by default. +func getShell() string { + if _, err := exec.LookPath("bash"); err == nil { + return "bash" + } + return "sh" +} + +func buildCredential(uid, gid *uint32) (*syscall.Credential, error) { + if uid == nil && gid == nil { + return nil, nil //nolint:nilnil + } + + cred := &syscall.Credential{} + if uid != nil { + cred.Uid = *uid + // Load user info to get primary GID and supplemental groups + u, err := user.LookupId(strconv.FormatUint(uint64(*uid), 10)) + if err == nil { + // Set primary GID if not explicitly provided + if gid == nil { + primaryGid, err := strconv.ParseUint(u.Gid, 10, 32) + if err == nil { + cred.Gid = uint32(primaryGid) + } + } + + // Load supplemental groups + gids, err := u.GroupIds() + if err == nil { + for _, g := range gids { + id, err := strconv.ParseUint(g, 10, 32) + if err == nil { + cred.Groups = append(cred.Groups, uint32(id)) + } + } + } + } + } + + // Override Gid if explicitly provided + if gid != nil { + cred.Gid = *gid + } + + return cred, nil +} + +// runCommand executes shell commands and streams their output. +func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error { + session := c.newContextID() + + signals := make(chan os.Signal, len(forwardSignals)+1) + defer close(signals) + signal.Notify(signals, forwardSignals...) + defer signal.Stop(signals) + + stdout, stderr, err := c.stdLogDescriptor(session) + if err != nil { + return fmt.Errorf("failed to get stdlog descriptor: %w", err) + } + defer stdout.Close() + defer stderr.Close() + stdoutPath := c.stdoutFileName(session) + stderrPath := c.stderrFileName(session) + + startAt := time.Now() + log.Info("received command: %v", log.SanitizeCommand(request.Code)) + shell := getShell() + cmd := exec.CommandContext(ctx, shell, "-c", request.Code) + extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) + cwd, err := pathutil.ExpandPathWithEnv(request.Cwd, extraEnv) + if err != nil { + return fmt.Errorf("resolve request cwd %s: %w", request.Cwd, err) + } + + // Configure credentials and process group + cred, err := buildCredential(request.Uid, request.Gid) + if err != nil { + return fmt.Errorf("failed to build credential: %w", err) + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Credential: cred, + } + + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Env = mergeEnvs(os.Environ(), extraEnv) + cmd.Dir = cwd + + done := make(chan struct{}, 1) + var wg sync.WaitGroup + wg.Add(2) + safego.Go(func() { + defer wg.Done() + c.tailStdPipe(stdoutPath, request.Hooks.OnExecuteStdout, done) + }) + safego.Go(func() { + defer wg.Done() + c.tailStdPipe(stderrPath, request.Hooks.OnExecuteStderr, done) + }) + + err = cmd.Start() + if err != nil { + close(done) + wg.Wait() + request.Hooks.OnExecuteInit(session) + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: "CommandExecError", + EValue: err.Error(), + Traceback: []string{err.Error()}, + }) + log.Error("CommandExecError: error starting commands: %v", err) + return nil + } + + kernel := &commandKernel{ + pid: cmd.Process.Pid, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + startedAt: startAt, + running: true, + content: request.Code, + isBackground: false, + } + c.storeCommandKernel(session, kernel) + request.Hooks.OnExecuteInit(session) + + safego.Go(func() { + for { + select { + case <-done: + // cmd.Wait() has returned (or start failed). The pid is + // about to be — or already has been — reaped, so we + // must not signal it. Execute()'s defer cancel() fires + // after every foreground command, including successful + // ones, so without this gate the SIGKILL below would + // run on a recycled pid/pgid and could kill an + // unrelated process group. + return + case <-ctx.Done(): + // Re-check `done` to avoid a race with cmd.Wait() + // returning concurrently. If cmd.Wait() has just + // finished, the leader pid may be reaped and recycled + // at any moment; signaling -pid would then target a + // foreign process group. + select { + case <-done: + return + default: + } + // Genuine cancellation (timeout, client disconnect, + // Interrupt). Kill the whole process group so children + // don't outlive the cancelled context. + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return + case sig := <-signals: + if sig == nil { + continue + } + // DO NOT forward syscall.SIGURG to children processes. + if sig != syscall.SIGCHLD && sig != syscall.SIGURG { + _ = syscall.Kill(-cmd.Process.Pid, sig.(syscall.Signal)) + } + } + } + }) + + err = cmd.Wait() + close(done) + wg.Wait() + if err != nil { + var eName, eValue string + var eCode int + var traceback []string + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + exitCode := exitError.ExitCode() + eName = "CommandExecError" + eValue = strconv.Itoa(exitCode) + eCode = exitCode + } else { + eName = "CommandExecError" + eValue = err.Error() + eCode = 1 + } + traceback = []string{err.Error()} + + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: eName, + EValue: eValue, + Traceback: traceback, + }) + + log.Error("CommandExecError: error running commands: %v", err) + c.markCommandFinished(session, eCode, err.Error()) + return nil + } + + c.markCommandFinished(session, 0, "") + request.Hooks.OnExecuteComplete(time.Since(startAt)) + return nil +} + +// runBackgroundCommand executes shell commands in detached mode. +func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { + session := c.newContextID() + request.Hooks.OnExecuteInit(session) + + pipe, err := c.combinedOutputDescriptor(session) + if err != nil { + cancel() + return fmt.Errorf("failed to get combined output descriptor: %w", err) + } + stdoutPath := c.combinedOutputFileName(session) + stderrPath := c.combinedOutputFileName(session) + + signals := make(chan os.Signal, len(forwardSignals)+1) + defer close(signals) + signal.Notify(signals, forwardSignals...) + defer signal.Stop(signals) + + startAt := time.Now() + log.Info("received command: %v", log.SanitizeCommand(request.Code)) + shell := getShell() + cmd := exec.CommandContext(ctx, shell, "-c", request.Code) + extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) + cwd, err := pathutil.ExpandPathWithEnv(request.Cwd, extraEnv) + if err != nil { + cancel() + return fmt.Errorf("resolve cwd: %w", err) + } + cmd.Dir = cwd + // Configure credentials and process group + cred, err := buildCredential(request.Uid, request.Gid) + if err != nil { + cancel() + return fmt.Errorf("build credential: %w", err) + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Credential: cred, + } + + cmd.Stdout = pipe + cmd.Stderr = pipe + cmd.Env = mergeEnvs(os.Environ(), extraEnv) + + // use DevNull as stdin so interactive programs exit immediately. + devNull, err := os.Open(os.DevNull) + if err == nil { + cmd.Stdin = devNull + defer devNull.Close() + } + + err = cmd.Start() + kernel := &commandKernel{ + pid: -1, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + startedAt: startAt, + running: true, + content: request.Code, + isBackground: true, + } + if err != nil { + cancel() + log.Error("CommandExecError: error starting commands: %v", err) + kernel.running = false + c.storeCommandKernel(session, kernel) + c.markCommandFinished(session, 255, err.Error()) + return fmt.Errorf("failed to start commands: %w", err) + } + + safego.Go(func() { + defer pipe.Close() + + kernel.running = true + kernel.pid = cmd.Process.Pid + c.storeCommandKernel(session, kernel) + + err = cmd.Wait() + cancel() + if err != nil { + log.Error("CommandExecError: error running commands: %v", err) + exitCode := 1 + var exitError *exec.ExitError + if errors.As(err, &exitError) { + exitCode = exitError.ExitCode() + } + c.markCommandFinished(session, exitCode, err.Error()) + return + } + c.markCommandFinished(session, 0, "") + }) + + // ensure we kill the whole process group if the context is cancelled (e.g., timeout). + safego.Go(func() { + <-ctx.Done() + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // best-effort + } + }) + + request.Hooks.OnExecuteComplete(time.Since(startAt)) + return nil +} diff --git a/components/execd/pkg/runtime/command_common.go b/components/execd/pkg/runtime/command_common.go new file mode 100644 index 0000000..804869b --- /dev/null +++ b/components/execd/pkg/runtime/command_common.go @@ -0,0 +1,178 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +// tailStdPipe streams appended log data until the process finishes. +func (c *Controller) tailStdPipe(file string, onExecute func(text string), done <-chan struct{}) { + lastPos := int64(0) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + mutex := &sync.Mutex{} + var lastWasCR bool + for { + select { + case <-done: + c.readFromPos(mutex, file, lastPos, onExecute, true, &lastWasCR) + return + case <-ticker.C: + newPos := c.readFromPos(mutex, file, lastPos, onExecute, false, &lastWasCR) + lastPos = newPos + } + } +} + +// getCommandKernel retrieves a command execution context. +func (c *Controller) getCommandKernel(sessionID string) *commandKernel { + if v, ok := c.commandClientMap.Load(sessionID); ok { + if kernel, ok := v.(*commandKernel); ok { + return kernel + } + } + return nil +} + +// storeCommandKernel registers a command execution context. +func (c *Controller) storeCommandKernel(sessionID string, kernel *commandKernel) { + c.commandClientMap.Store(sessionID, kernel) +} + +// stdLogDescriptor creates temporary files for capturing command output. +// It ensures the temp directory exists before opening files, so that commands +// continue to work even after the /tmp directory has been removed and recreated. +func (c *Controller) stdLogDescriptor(session string) (io.WriteCloser, io.WriteCloser, error) { + logDir := os.TempDir() + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, nil, fmt.Errorf("failed to create temp dir %s: %w", logDir, err) + } + + stdout, err := os.OpenFile(c.stdoutFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + return nil, nil, err + } + stderr, err := os.OpenFile(c.stderrFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + stdout.Close() + return nil, nil, err + } + + return stdout, stderr, nil +} + +func (c *Controller) combinedOutputDescriptor(session string) (io.WriteCloser, error) { + logDir := os.TempDir() + if err := os.MkdirAll(logDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create temp dir %s: %w", logDir, err) + } + return os.OpenFile(c.combinedOutputFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) +} + +// stdoutFileName constructs the stdout log path. +func (c *Controller) stdoutFileName(session string) string { + return filepath.Join(os.TempDir(), session+".stdout") +} + +// stderrFileName constructs the stderr log path. +func (c *Controller) stderrFileName(session string) string { + return filepath.Join(os.TempDir(), session+".stderr") +} + +func (c *Controller) combinedOutputFileName(session string) string { + return filepath.Join(os.TempDir(), session+".output") +} + +// readFromPos streams new content from a file starting at startPos. +// lastWasCR persists CRLF detection across calls so a \r\n pair split between +// two polls does not surface a spurious blank line for the trailing \n. +func (c *Controller) readFromPos(mutex *sync.Mutex, filepath string, startPos int64, onExecute func(string), flushIncomplete bool, lastWasCR *bool) int64 { + if !mutex.TryLock() { + return -1 + } + defer mutex.Unlock() + + file, err := os.Open(filepath) + if err != nil { + return startPos + } + defer file.Close() + + _, _ = file.Seek(startPos, 0) //nolint:errcheck + + reader := bufio.NewReader(file) + var buffer bytes.Buffer + var currentPos int64 = startPos + cr := false + if lastWasCR != nil { + cr = *lastWasCR + } + defer func() { + if lastWasCR != nil { + *lastWasCR = cr + } + }() + + for { + b, err := reader.ReadByte() + if err != nil { + if err == io.EOF { + // If buffer has content but no newline, flush if needed, otherwise wait for next read + if flushIncomplete && buffer.Len() > 0 { + onExecute(buffer.String()) + buffer.Reset() + } + } + break + } + currentPos++ + + // Check if it's a line terminator (\n or \r) + if b == '\n' || b == '\r' { + switch { + case buffer.Len() > 0: + // Flush the line content without the terminator + onExecute(buffer.String()) + buffer.Reset() + case b == '\n' && cr: + // Second half of a \r\n pair; already emitted on \r + default: + // Standalone blank line; surface it so callers see the gap + onExecute("\n") + } + cr = (b == '\r') + continue + } + + cr = false + buffer.WriteByte(b) + } + + endPos, _ := file.Seek(0, 1) + // If the last read position doesn't end with a newline, return buffer start position and wait for next flush + if !flushIncomplete && buffer.Len() > 0 { + return currentPos - int64(buffer.Len()) + } + return endPos +} diff --git a/components/execd/pkg/runtime/command_signal_test.go b/components/execd/pkg/runtime/command_signal_test.go new file mode 100644 index 0000000..bd23b52 --- /dev/null +++ b/components/execd/pkg/runtime/command_signal_test.go @@ -0,0 +1,246 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/stretchr/testify/require" +) + +// TestRunCommand_CancelKillsChildren verifies that cancelling the context +// terminates not only the bash group leader but also its descendant +// processes. Regression test for +// https://github.com/alibaba/OpenSandbox/issues/922. +func TestRunCommand_CancelKillsChildren(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + pidFile := filepath.Join(t.TempDir(), "child.pid") + + c := NewController("", "") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + started := make(chan struct{}) + var once sync.Once + + req := &ExecuteCodeRequest{ + // Spawn a sleep child, record its pid, then wait so the bash + // leader stays alive until the context is cancelled. + Code: `sleep 30 & echo $! > "` + pidFile + `"; echo READY; wait`, + Cwd: t.TempDir(), + Timeout: 30 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(_ string) {}, + OnExecuteStdout: func(s string) { + if strings.TrimSpace(s) == "READY" { + once.Do(func() { close(started) }) + } + }, + OnExecuteStderr: func(_ string) {}, + OnExecuteError: func(_ *execute.ErrorOutput) {}, + OnExecuteComplete: func(_ time.Duration) {}, + }, + } + + done := make(chan struct{}) + go func() { + _ = c.runCommand(ctx, req) + close(done) + }() + + select { + case <-started: + case <-time.After(10 * time.Second): + cancel() + <-done + t.Fatal("command did not emit READY in time") + } + + pidBytes, err := os.ReadFile(pidFile) + require.NoError(t, err, "expected child pid file") + childPid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes))) + require.NoError(t, err) + require.Positive(t, childPid) + + require.NoError(t, syscall.Kill(childPid, 0), "child should be alive before cancel") + + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runCommand did not return after cancel") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(childPid, 0); err != nil { + require.True(t, errors.Is(err, syscall.ESRCH), + "unexpected liveness probe error: %v", err) + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("child pid %d still alive 2s after cancel — process leak", childPid) +} + +// TestInterrupt_AfterFinished_ReturnsError verifies that an Interrupt +// arriving after the command has completed does not signal a recycled PID. +// Without this guard, group-wide kill would amplify the stale-PID hazard +// to every process in an unrelated process group. +func TestInterrupt_AfterFinished_ReturnsError(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var session string + completeCh := make(chan struct{}, 1) + req := &ExecuteCodeRequest{ + Code: `echo done`, + Cwd: t.TempDir(), + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { session = s }, + OnExecuteStdout: func(_ string) {}, + OnExecuteStderr: func(_ string) {}, + OnExecuteError: func(_ *execute.ErrorOutput) {}, + OnExecuteComplete: func(_ time.Duration) { completeCh <- struct{}{} }, + }, + } + require.NoError(t, c.runCommand(ctx, req)) + + select { + case <-completeCh: + case <-time.After(3 * time.Second): + t.Fatal("command did not complete in time") + } + require.NotEmpty(t, session) + + err := c.Interrupt(session) + require.Error(t, err, "Interrupt on finished session must error") + require.Contains(t, err.Error(), "not running") + + snap := c.commandSnapshot(session) + require.NotNil(t, snap) + require.False(t, snap.running, "running flag should be cleared") + require.Equal(t, 0, snap.pid, "pid should be cleared to avoid stale-PID kill") +} + +// TestKillPid_ZombieLeaderDoesNotFail verifies that killPid does not +// return an error when a group leader becomes a zombie before its parent +// has reaped it. kill(-pid, 0) keeps reporting the group as observable +// while the zombie lingers, but SIGKILL has already been delivered and +// the kernel will tear the group down once Wait() runs. Treating that +// state as a failure caused Interrupt to surface a 500 even though the +// kill succeeded. +func TestKillPid_ZombieLeaderDoesNotFail(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + cmd := exec.Command("bash", "-c", `sleep 30 & wait`) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + require.NoError(t, cmd.Start()) + // Deliberately omit a reaper goroutine so the leader stays as a + // zombie after kill — that is the condition we want to exercise. + t.Cleanup(func() { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _, _ = cmd.Process.Wait() + }) + + // Give bash a moment to spawn the sleep child so the group has more + // than just the leader. + time.Sleep(100 * time.Millisecond) + + c := &Controller{} + require.NoError(t, c.killPid(cmd.Process.Pid), + "slow post-SIGKILL teardown must not be reported as a hard failure") +} + +// TestKillPid_TerminatesEntireProcessGroup verifies that killPid signals +// the whole process group, not just the leader. Regression test for +// https://github.com/alibaba/OpenSandbox/issues/922. +func TestKillPid_TerminatesEntireProcessGroup(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + pidFile := filepath.Join(t.TempDir(), "child.pid") + cmd := exec.Command("bash", "-c", + `sleep 30 & echo $! > "`+pidFile+`"; wait`) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + require.NoError(t, cmd.Start()) + // Reap the leader concurrently so it doesn't linger as a zombie that + // keeps the process group "alive" from killPid's liveness probe + // perspective. Mirrors how runCommand's cmd.Wait() reaps in production. + waitDone := make(chan struct{}) + go func() { + _, _ = cmd.Process.Wait() + close(waitDone) + }() + t.Cleanup(func() { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + <-waitDone + }) + + var childPid int + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(pidFile); err == nil { + if pid, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && pid > 0 { + childPid = pid + break + } + } + time.Sleep(50 * time.Millisecond) + } + require.Positive(t, childPid, "failed to capture child pid") + require.NoError(t, syscall.Kill(childPid, 0), "child should be alive before kill") + + c := &Controller{} + require.NoError(t, c.killPid(cmd.Process.Pid)) + + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(childPid, 0); err != nil { + require.True(t, errors.Is(err, syscall.ESRCH), + "unexpected liveness probe error: %v", err) + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("child pid %d still alive 2s after killPid — process leak", childPid) +} diff --git a/components/execd/pkg/runtime/command_status.go b/components/execd/pkg/runtime/command_status.go new file mode 100644 index 0000000..c0883d0 --- /dev/null +++ b/components/execd/pkg/runtime/command_status.go @@ -0,0 +1,138 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "fmt" + "io" + "os" + "time" +) + +// CommandStatus describes the lifecycle state of a command. +type CommandStatus struct { + Session string `json:"session"` + Running bool `json:"running"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Content string `json:"content,omitempty"` +} + +// CommandOutput contains non-streamed stdout/stderr plus status. +type CommandOutput struct { + CommandStatus + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func (c *Controller) commandSnapshot(session string) *commandKernel { + c.mu.RLock() + defer c.mu.RUnlock() + + var kernel *commandKernel + if v, ok := c.commandClientMap.Load(session); ok { + kernel, _ = v.(*commandKernel) + } + if kernel == nil { + return nil + } + + cp := *kernel + return &cp +} + +// GetCommandStatus returns the execution status for a command session. +func (c *Controller) GetCommandStatus(session string) (*CommandStatus, error) { + kernel := c.commandSnapshot(session) + if kernel == nil { + return nil, fmt.Errorf("command not found: %s", session) + } + + status := &CommandStatus{ + Session: session, + Running: kernel.running, + ExitCode: kernel.exitCode, + Error: kernel.errMsg, + StartedAt: kernel.startedAt, + FinishedAt: kernel.finishedAt, + Content: kernel.content, + } + return status, nil +} + +// SeekBackgroundCommandOutput returns accumulated stdout/stderr and status for a session. +func (c *Controller) SeekBackgroundCommandOutput(session string, cursor int64) ([]byte, int64, error) { + kernel := c.commandSnapshot(session) + if kernel == nil { + return nil, -1, fmt.Errorf("command not found: %s", session) + } + + if !kernel.isBackground { + return nil, -1, fmt.Errorf("command %s is not running in background", session) + } + + file, err := os.Open(kernel.stdoutPath) + if err != nil { + return nil, -1, fmt.Errorf("error open combined output file for command %s: %w", session, err) + } + defer file.Close() + + // Seek to the cursor position + _, err = file.Seek(cursor, 0) + if err != nil { + return nil, -1, fmt.Errorf("error seek file: %w", err) + } + + // Read all content from cursor to end + data, err := io.ReadAll(file) + if err != nil { + return nil, -1, fmt.Errorf("error read file: %w", err) + } + + // Get current file position (end of file) + currentPos, err := file.Seek(0, 1) + if err != nil { + return nil, -1, fmt.Errorf("error get current position: %w", err) + } + + return data, currentPos, nil +} + +// markCommandFinished updates bookkeeping when a command exits. +func (c *Controller) markCommandFinished(session string, exitCode int, errMsg string) { + now := time.Now() + + c.mu.Lock() + defer c.mu.Unlock() + + var kernel *commandKernel + if v, ok := c.commandClientMap.Load(session); ok { + kernel, _ = v.(*commandKernel) + } + if kernel == nil { + return + } + + kernel.exitCode = &exitCode + kernel.errMsg = errMsg + kernel.running = false + kernel.finishedAt = &now + // Clear the PID so a late or retried Interrupt cannot signal a recycled + // process. Group-wide kill would otherwise amplify the impact of a + // stale-PID hit to every process in the unrelated process group. + kernel.pid = 0 +} diff --git a/components/execd/pkg/runtime/command_status_test.go b/components/execd/pkg/runtime/command_status_test.go new file mode 100644 index 0000000..12dae23 --- /dev/null +++ b/components/execd/pkg/runtime/command_status_test.go @@ -0,0 +1,153 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestGetCommandStatus_NotFound(t *testing.T) { + c := NewController("", "") + + _, err := c.GetCommandStatus("missing") + require.Error(t, err, "expected error for missing session") +} + +func TestGetCommandStatus_Running(t *testing.T) { + c := NewController("", "") + + var session string + req := &ExecuteCodeRequest{ + Language: BackgroundCommand, + Code: "sleep 2", + Hooks: ExecuteResultHook{ + OnExecuteInit: func(id string) { session = id }, + OnExecuteComplete: func(time.Duration) {}, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, c.runBackgroundCommand(ctx, cancel, req)) + require.NotEmpty(t, session, "session should be set by OnExecuteInit") + + // Poll until status is registered (runBackgroundCommand stores kernel asynchronously). + deadline := time.Now().Add(5 * time.Second) + var ( + status *CommandStatus + err error + ) + for time.Now().Before(deadline) { + status, err = c.GetCommandStatus(session) + if err == nil { + break + } + if strings.Contains(err.Error(), "not found") { + time.Sleep(50 * time.Millisecond) + continue + } + require.NoError(t, err, "GetCommandStatus unexpected error") + } + require.NoError(t, err, "GetCommandStatus error after retry") + + require.NotNil(t, status) + require.True(t, status.Running, "expected running=true") + require.Nil(t, status.ExitCode, "expected exitCode to be nil while running") + require.Nil(t, status.FinishedAt, "expected finishedAt to be nil while running") + require.False(t, status.StartedAt.IsZero(), "expected startedAt to be set") + t.Log(status) +} + +func TestSeekBackgroundCommandOutput_Completed(t *testing.T) { + c := NewController("", "") + + tmpDir := t.TempDir() + session := "sess-done" + stdoutPath := filepath.Join(tmpDir, session+".stdout") + + stdoutContent := "hello stdout" + require.NoError(t, os.WriteFile(stdoutPath, []byte(stdoutContent), 0o644)) + + started := time.Now().Add(-2 * time.Second) + finished := time.Now() + exitCode := 0 + kernel := &commandKernel{ + pid: 456, + stdoutPath: stdoutPath, + isBackground: true, + startedAt: started, + finishedAt: &finished, + exitCode: &exitCode, + errMsg: "", + running: false, + } + c.storeCommandKernel(session, kernel) + + output, cursor, err := c.SeekBackgroundCommandOutput(session, 0) + require.NoError(t, err, "GetCommandOutput error") + + require.Greater(t, cursor, int64(0), "expected cursor>=0") + require.Equal(t, stdoutContent, string(output)) +} + +func TestSeekBackgroundCommandOutput_WithRunBackgroundCommand(t *testing.T) { + c := NewController("", "") + + expected := "line1\nline2\n" + var session string + req := &ExecuteCodeRequest{ + Language: BackgroundCommand, + Code: "printf 'line1\nline2\n'", + Hooks: ExecuteResultHook{ + OnExecuteInit: func(id string) { session = id }, + OnExecuteComplete: func(executionTime time.Duration) {}, + // other hooks unused in this test + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + require.NoError(t, c.runBackgroundCommand(ctx, cancel, req)) + require.NotEmpty(t, session, "session should be set by OnExecuteInit") + + var ( + output []byte + cursor int64 + err error + ) + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + output, cursor, err = c.SeekBackgroundCommandOutput(session, 0) + if err == nil && len(output) > 0 { + break + } + time.Sleep(100 * time.Millisecond) + } + require.NoError(t, err, "SeekBackgroundCommandOutput error") + require.Equal(t, expected, string(output)) + require.GreaterOrEqual(t, cursor, int64(len(expected)), "cursor should advance to end of file") + + // incremental seek from current cursor should return empty data and same-or-higher cursor + output2, cursor2, err := c.SeekBackgroundCommandOutput(session, cursor) + require.NoError(t, err, "SeekBackgroundCommandOutput (second call) error") + require.Empty(t, output2, "expected no new output") + require.GreaterOrEqual(t, cursor2, cursor, "cursor should not move backwards") +} diff --git a/components/execd/pkg/runtime/command_test.go b/components/execd/pkg/runtime/command_test.go new file mode 100644 index 0000000..3e4ba1d --- /dev/null +++ b/components/execd/pkg/runtime/command_test.go @@ -0,0 +1,498 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + goruntime "runtime" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadFromPos_SplitsOnCRAndLF(t *testing.T) { + tmp := t.TempDir() + logFile := filepath.Join(tmp, "stdout.log") + + mutex := &sync.Mutex{} + + initial := "line1\nprog 10%\rprog 20%\rprog 30%\nlast\n" + require.NoError(t, os.WriteFile(logFile, []byte(initial), 0o644)) + + var got []string + c := &Controller{} + nextPos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false, nil) + + want := []string{"line1", "prog 10%", "prog 20%", "prog 30%", "last"} + require.Len(t, got, len(want)) + for i := range want { + require.Equal(t, want[i], got[i], "token[%d] mismatch", i) + } + + // append more content and ensure incremental read only yields the new part + appendPart := "tail1\r\ntail2\n" + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = f.WriteString(appendPart) + require.NoError(t, err, "append write") + _ = f.Close() + + got = got[:0] + c.readFromPos(mutex, logFile, nextPos, func(s string) { got = append(got, s) }, false, nil) + want = []string{"tail1", "tail2"} + require.Len(t, got, len(want)) + for i := range want { + require.Equal(t, want[i], got[i], "incremental token[%d] mismatch", i) + } +} + +func TestReadFromPos_LongLine(t *testing.T) { + tmp := t.TempDir() + logFile := filepath.Join(tmp, "stdout.log") + + // construct a single line larger than the default 64KB, but under 5MB + longLine := strings.Repeat("x", 256*1024) + "\n" // 256KB + require.NoError(t, os.WriteFile(logFile, []byte(longLine), 0o644)) + + var got []string + c := &Controller{} + c.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false, nil) + + require.Len(t, got, 1, "expected one token") + require.Equal(t, strings.TrimSuffix(longLine, "\n"), got[0], "long line mismatch") +} + +func TestReadFromPos_FlushesTrailingLine(t *testing.T) { + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "stdout.log") + content := []byte("line1\nlastline-without-newline") + err := os.WriteFile(file, content, 0o644) + assert.NoError(t, err) + + c := NewController("", "") + mutex := &sync.Mutex{} + var lines []string + onExecute := func(text string) { + lines = append(lines, text) + } + + // First read: should only get complete lines with newlines + pos := c.readFromPos(mutex, file, 0, onExecute, false, nil) + assert.GreaterOrEqual(t, pos, int64(0)) + assert.Equal(t, []string{"line1"}, lines) + + // Flush at end: should output the last line (without newline) + c.readFromPos(mutex, file, pos, onExecute, true, nil) + assert.Equal(t, []string{"line1", "lastline-without-newline"}, lines) +} + +func TestReadFromPos_PreservesBlankLines(t *testing.T) { + tmp := t.TempDir() + logFile := filepath.Join(tmp, "stdout.log") + + // Mix of single newlines, consecutive blank lines, leading blank, and CRLF. + initial := "a\n\nb\n\n\nc\n\r\nd\n" + require.NoError(t, os.WriteFile(logFile, []byte(initial), 0o644)) + + var got []string + c := &Controller{} + c.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false, nil) + + want := []string{"a", "\n", "b", "\n", "\n", "c", "\n", "d"} + require.Equal(t, want, got) +} + +// TestReadFromPos_CRLFAcrossPolls ensures a \r\n pair that arrives in two +// successive polls does not emit a spurious blank line for the trailing \n. +// Reproduces the regression on Windows/cmd writers that flush \r before \n. +func TestReadFromPos_CRLFAcrossPolls(t *testing.T) { + tmp := t.TempDir() + logFile := filepath.Join(tmp, "stdout.log") + + require.NoError(t, os.WriteFile(logFile, []byte("a\r"), 0o644)) + + var got []string + c := &Controller{} + mutex := &sync.Mutex{} + var lastWasCR bool + pos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false, &lastWasCR) + require.Equal(t, []string{"a"}, got) + require.True(t, lastWasCR, "CR state must persist for next poll") + + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = f.WriteString("\nb\n") + require.NoError(t, err) + _ = f.Close() + + got = got[:0] + c.readFromPos(mutex, logFile, pos, func(s string) { got = append(got, s) }, false, &lastWasCR) + require.Equal(t, []string{"b"}, got, "trailing \\n of split CRLF must not emit a blank line") +} + +// TestReadFromPos_BlankCRLFAcrossPolls ensures a blank \r\n line split across +// polls is emitted as a single blank, not duplicated. +func TestReadFromPos_BlankCRLFAcrossPolls(t *testing.T) { + tmp := t.TempDir() + logFile := filepath.Join(tmp, "stdout.log") + + require.NoError(t, os.WriteFile(logFile, []byte("\r"), 0o644)) + + var got []string + c := &Controller{} + mutex := &sync.Mutex{} + var lastWasCR bool + pos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false, &lastWasCR) + require.Equal(t, []string{"\n"}, got) + require.True(t, lastWasCR) + + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = f.WriteString("\n") + require.NoError(t, err) + _ = f.Close() + + got = got[:0] + c.readFromPos(mutex, logFile, pos, func(s string) { got = append(got, s) }, false, &lastWasCR) + require.Empty(t, got, "trailing \\n of split blank CRLF must not emit a second blank") +} + +func TestRunCommand_Echo(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("bash not available on windows") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + sessionID string + stdoutLines []string + stderrLines []string + completeCh = make(chan struct{}, 1) + ) + + req := &ExecuteCodeRequest{ + Code: `echo "hello"; echo "errline" 1>&2`, + Cwd: t.TempDir(), + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { sessionID = s }, + OnExecuteStdout: func(s string) { + stdoutLines = append(stdoutLines, s) + }, + OnExecuteStderr: func(s string) { + stderrLines = append(stderrLines, s) + }, + OnExecuteError: func(err *execute.ErrorOutput) { + require.Failf(t, "unexpected error hook", "%+v", err) + }, + OnExecuteComplete: func(_ time.Duration) { + completeCh <- struct{}{} + }, + }, + } + + require.NoError(t, c.runCommand(ctx, req)) + + select { + case <-completeCh: + case <-time.After(2 * time.Second): + require.Fail(t, "timeout waiting for completion hook") + } + + require.NotEmpty(t, sessionID, "expected session id to be set") + require.Equal(t, []string{"hello"}, stdoutLines) + require.Equal(t, []string{"errline"}, stderrLines) +} + +func TestRunCommand_Error(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("bash not available on windows") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + sessionID string + gotErr *execute.ErrorOutput + completeCh = make(chan struct{}, 2) + stdoutLines []string + stderrLines []string + ) + + req := &ExecuteCodeRequest{ + Code: `echo "before"; exit 3`, + Cwd: t.TempDir(), + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { sessionID = s }, + OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) }, + OnExecuteStderr: func(s string) { stderrLines = append(stderrLines, s) }, + OnExecuteError: func(err *execute.ErrorOutput) { + gotErr = err + completeCh <- struct{}{} + }, + OnExecuteComplete: func(_ time.Duration) { + completeCh <- struct{}{} + }, + }, + } + + require.NoError(t, c.runCommand(ctx, req)) + + select { + case <-completeCh: + case <-time.After(2 * time.Second): + require.Fail(t, "timeout waiting for completion hook") + } + + require.NotEmpty(t, sessionID, "expected session id to be set") + require.Equal(t, []string{"before"}, stdoutLines) + require.Empty(t, stderrLines, "expected no stderr") + require.NotNil(t, gotErr, "expected error hook to be called") + require.Equal(t, "CommandExecError", gotErr.EName) + require.Equal(t, "3", gotErr.EValue) +} + +func TestRunCommand_ExpandsHomeInCwd(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("bash not available on windows") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + home := t.TempDir() + target := filepath.Join(home, "workspace") + require.NoError(t, os.MkdirAll(target, 0o755)) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + c := NewController("", "") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var stdoutLines []string + req := &ExecuteCodeRequest{ + Code: `pwd`, + Cwd: "~/workspace", + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(_ string) {}, + OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) }, + OnExecuteStderr: func(_ string) {}, + OnExecuteError: func(err *execute.ErrorOutput) { + require.Failf(t, "unexpected error hook", "%+v", err) + }, + OnExecuteComplete: func(_ time.Duration) {}, + }, + } + + require.NoError(t, c.runCommand(ctx, req)) + + targetRealPath, err := filepath.EvalSymlinks(target) + require.NoError(t, err) + targetRealPath = filepath.Clean(targetRealPath) + + found := false + for _, line := range stdoutLines { + p := strings.TrimSpace(line) + if p == "" { + continue + } + pRealPath, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + if filepath.Clean(pRealPath) == targetRealPath { + found = true + break + } + } + require.True(t, found, "pwd output does not match expected cwd; got=%v target=%s", stdoutLines, target) +} + +func TestRunCommand_ExpandsCwdFromRequestEnvWithHigherPriority(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("bash not available on windows") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + processDir := t.TempDir() + requestDir := t.TempDir() + t.Setenv("WORKDIR", processDir) + + c := NewController("", "") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + stdoutLines []string + gotErr *execute.ErrorOutput + ) + req := &ExecuteCodeRequest{ + Code: `pwd`, + Cwd: `$WORKDIR`, + Timeout: 5 * time.Second, + Envs: map[string]string{ + "WORKDIR": requestDir, + }, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(_ string) {}, + OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) }, + OnExecuteStderr: func(_ string) {}, + OnExecuteError: func(err *execute.ErrorOutput) { + gotErr = err + }, + OnExecuteComplete: func(_ time.Duration) {}, + }, + } + + require.NoError(t, c.runCommand(ctx, req)) + require.Nil(t, gotErr, "expected cwd expansion to use request env") + + requestRealPath, err := filepath.EvalSymlinks(requestDir) + require.NoError(t, err) + requestRealPath = filepath.Clean(requestRealPath) + + found := false + for _, line := range stdoutLines { + p := strings.TrimSpace(line) + if p == "" { + continue + } + pRealPath, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + if filepath.Clean(pRealPath) == requestRealPath { + found = true + break + } + } + require.True(t, found, "pwd output does not match request env cwd; got=%v requestDir=%s", stdoutLines, requestDir) +} + +func TestRunCommand_StartErrorIncludesTraceback(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("bash not available on windows") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found in PATH") + } + + c := NewController("", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + sessionID string + gotErr *execute.ErrorOutput + completeCalled bool + ) + + req := &ExecuteCodeRequest{ + Code: `echo "hello"`, + Cwd: filepath.Join(t.TempDir(), "missing"), + Timeout: 5 * time.Second, + Hooks: ExecuteResultHook{ + OnExecuteInit: func(s string) { sessionID = s }, + OnExecuteError: func(err *execute.ErrorOutput) { + gotErr = err + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalled = true + }, + }, + } + + require.NoError(t, c.runCommand(ctx, req)) + + require.NotEmpty(t, sessionID, "expected session id to be set") + require.NotNil(t, gotErr, "expected error hook to be called") + require.Equal(t, "CommandExecError", gotErr.EName) + require.NotEmpty(t, gotErr.Traceback, "expected traceback to be populated") + require.Equal(t, gotErr.EValue, gotErr.Traceback[0]) + require.False(t, completeCalled, "did not expect completion hook on start failure") +} + +// TestStdLogDescriptor_AutoCreatesTempDir verifies that stdLogDescriptor +// recreates the temp directory when it has been deleted, rather than failing. +// Regression test for https://github.com/alibaba/OpenSandbox/issues/400. +func TestStdLogDescriptor_AutoCreatesTempDir(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("TMPDIR env var has no effect on Windows") + } + + // Point os.TempDir() at a path that does not yet exist. + missingDir := filepath.Join(t.TempDir(), "deleted_tmp") + t.Setenv("TMPDIR", missingDir) + + c := NewController("", "") + stdout, stderr, err := c.stdLogDescriptor("test-session") + require.NoError(t, err) + stdout.Close() + stderr.Close() + + // The directory must have been created. + info, err := os.Stat(missingDir) + require.NoError(t, err, "expected temp dir to be created, stat error") + require.True(t, info.IsDir(), "expected %s to be a directory", missingDir) +} + +// TestCombinedOutputDescriptor_AutoCreatesTempDir verifies that +// combinedOutputDescriptor also recreates the temp directory when missing. +// Regression test for https://github.com/alibaba/OpenSandbox/issues/400. +func TestCombinedOutputDescriptor_AutoCreatesTempDir(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("TMPDIR env var has no effect on Windows") + } + + missingDir := filepath.Join(t.TempDir(), "deleted_tmp") + t.Setenv("TMPDIR", missingDir) + + c := NewController("", "") + f, err := c.combinedOutputDescriptor("test-session") + require.NoError(t, err) + f.Close() + + info, err := os.Stat(missingDir) + require.NoError(t, err, "expected temp dir to be created, stat error") + require.True(t, info.IsDir(), "expected %s to be a directory", missingDir) +} diff --git a/components/execd/pkg/runtime/command_windows.go b/components/execd/pkg/runtime/command_windows.go new file mode 100644 index 0000000..5c720dd --- /dev/null +++ b/components/execd/pkg/runtime/command_windows.go @@ -0,0 +1,195 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package runtime + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "sync" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/internal/safego" +) + +// runCommand executes shell commands and streams their output on Windows. +func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error { + session := c.newContextID() + request.Hooks.OnExecuteInit(session) + + stdout, stderr, err := c.stdLogDescriptor(session) + if err != nil { + return fmt.Errorf("failed to get stdlog descriptor: %w", err) + } + + startAt := time.Now() + log.Info("received command: %v", log.SanitizeCommand(request.Code)) + cmd := exec.CommandContext(ctx, "cmd", "/C", request.Code) + extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) + cwd, err := pathutil.ExpandPathWithEnv(request.Cwd, extraEnv) + if err != nil { + return fmt.Errorf("resolve cwd: %w", err) + } + + cmd.Stdout = stdout + cmd.Stderr = stderr + cmd.Dir = cwd + cmd.Env = mergeEnvs(os.Environ(), extraEnv) + + done := make(chan struct{}, 1) + var wg sync.WaitGroup + wg.Add(2) + safego.Go(func() { + defer wg.Done() + c.tailStdPipe(c.stdoutFileName(session), request.Hooks.OnExecuteStdout, done) + }) + safego.Go(func() { + defer wg.Done() + c.tailStdPipe(c.stderrFileName(session), request.Hooks.OnExecuteStderr, done) + }) + + err = cmd.Start() + if err != nil { + close(done) + wg.Wait() + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "CommandExecError", EValue: err.Error()}) + log.Error("CommandExecError: error starting commands: %v", err) + return nil + } + + kernel := &commandKernel{ + pid: cmd.Process.Pid, + content: request.Code, + isBackground: false, + } + c.storeCommandKernel(session, kernel) + + err = cmd.Wait() + close(done) + wg.Wait() + if err != nil { + var eName, eValue string + var traceback []string + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + exitCode := exitError.ExitCode() + eName = "CommandExecError" + eValue = strconv.Itoa(exitCode) + } else { + eName = "CommandExecError" + eValue = err.Error() + } + traceback = []string{err.Error()} + + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: eName, + EValue: eValue, + Traceback: traceback, + }) + + log.Error("CommandExecError: error running commands: %v", err) + return nil + } + request.Hooks.OnExecuteComplete(time.Since(startAt)) + return nil +} + +// runBackgroundCommand executes shell commands in detached mode on Windows. +func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { + session := c.newContextID() + request.Hooks.OnExecuteInit(session) + + pipe, err := c.combinedOutputDescriptor(session) + if err != nil { + return fmt.Errorf("failed to get combined output descriptor: %w", err) + } + stdoutPath := c.combinedOutputFileName(session) + stderrPath := c.combinedOutputFileName(session) + + startAt := time.Now() + log.Info("received command: %v", log.SanitizeCommand(request.Code)) + cmd := exec.CommandContext(ctx, "cmd", "/C", request.Code) + extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) + cwd, err := pathutil.ExpandPathWithEnv(request.Cwd, extraEnv) + if err != nil { + return fmt.Errorf("resolve cwd: %w", err) + } + + cmd.Dir = cwd + cmd.Stdout = pipe + cmd.Stderr = pipe + cmd.Env = mergeEnvs(os.Environ(), extraEnv) + + devNull, _ := os.OpenFile(os.DevNull, os.O_RDWR, 0) // best-effort, ignore error + cmd.Stdin = devNull + + safego.Go(func() { + err := cmd.Start() + if err != nil { + log.Error("CommandExecError: error starting commands: %v", err) + pipe.Close() // best-effort + cancel() + return + } + + kernel := &commandKernel{ + pid: cmd.Process.Pid, + content: request.Code, + stdoutPath: stdoutPath, + stderrPath: stderrPath, + startedAt: startAt, + running: true, + isBackground: true, + } + c.storeCommandKernel(session, kernel) + + safego.Go(func() { + <-ctx.Done() + if cmd.Process != nil { + _ = cmd.Process.Kill() // best-effort + } + }) + + err = cmd.Wait() + cancel() + pipe.Close() // best-effort + devNull.Close() // best-effort + + if err != nil { + log.Error("CommandExecError: error running commands: %v", err) + exitCode := 1 + var exitError *exec.ExitError + if errors.As(err, &exitError) { + exitCode = exitError.ExitCode() + } + c.markCommandFinished(session, exitCode, err.Error()) + return + } + c.markCommandFinished(session, 0, "") + }) + + request.Hooks.OnExecuteComplete(time.Since(startAt)) + return nil +} diff --git a/components/execd/pkg/runtime/context.go b/components/execd/pkg/runtime/context.go new file mode 100644 index 0000000..df057be --- /dev/null +++ b/components/execd/pkg/runtime/context.go @@ -0,0 +1,306 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "k8s.io/client-go/util/retry" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter" + jupytersession "github.com/alibaba/opensandbox/execd/pkg/jupyter/session" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" +) + +// CreateContext provisions a kernel-backed session and returns its ID. +// Bash language uses Jupyter kernel like other languages; for pipe-based bash sessions use CreateBashSession (session API). +func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { + // Create a new Jupyter session. + var ( + client *jupyter.Client + session *jupytersession.Session + err error + ) + + err = retry.OnError(kernelWaitingBackoff, func(err error) bool { + log.Error("failed to create session, retrying: %v", err) + return err != nil + }, func() error { + client, session, err = c.createJupyterContext(*req) + return err + }) + if err != nil { + return "", err + } + + kernel := &jupyterKernel{ + kernelID: session.Kernel.ID, + client: client, + language: req.Language, + } + c.storeJupyterKernel(session.ID, kernel) + + return session.ID, nil +} + +func (c *Controller) DeleteContext(session string) error { + return c.deleteSessionAndCleanup(session) +} + +func (c *Controller) GetContext(session string) (CodeContext, error) { + kernel := c.getJupyterKernel(session) + if kernel == nil { + return CodeContext{}, ErrContextNotFound + } + return CodeContext{ + ID: session, + Language: kernel.language, + }, nil +} + +func (c *Controller) ListContext(language string) ([]CodeContext, error) { + switch language { + case Command.String(), BackgroundCommand.String(), SQL.String(): + return nil, fmt.Errorf("unsupported language context operation: %s", language) + case "": + return c.listAllContexts() + default: + return c.listLanguageContexts(Language(language)) + } +} + +func (c *Controller) DeleteLanguageContext(language Language) error { + contexts, err := c.listLanguageContexts(language) + if err != nil { + return err + } + + seen := make(map[string]struct{}) + for _, context := range contexts { + if _, ok := seen[context.ID]; ok { + continue + } + seen[context.ID] = struct{}{} + + if err := c.deleteSessionAndCleanup(context.ID); err != nil { + return fmt.Errorf("error deleting context %s: %w", context.ID, err) + } + } + return nil +} + +func (c *Controller) deleteSessionAndCleanup(session string) error { + if c.getJupyterKernel(session) == nil { + return ErrContextNotFound + } + if err := c.jupyterClient().DeleteSession(session); err != nil { + return err + } + c.jupyterClientMap.Delete(session) + c.deleteDefaultSessionByID(session) + return nil +} + +func (c *Controller) newContextID() string { + return strings.ReplaceAll(uuid.New().String(), "-", "") +} + +func (c *Controller) newIpynbPath(sessionID, cwd string) (string, error) { + resolvedCwd, err := pathutil.ExpandPath(cwd) + if err != nil { + return "", err + } + if cwd != "" { + err := os.MkdirAll(resolvedCwd, os.ModePerm) + if err != nil { + return "", err + } + } + + return filepath.Join(resolvedCwd, fmt.Sprintf("%s.ipynb", sessionID)), nil +} + +// createDefaultLanguageJupyterContext prewarms a session for stateless execution. +func (c *Controller) createDefaultLanguageJupyterContext(language Language) error { + if c.getDefaultLanguageSession(language) != "" { + return nil + } + + var ( + client *jupyter.Client + session *jupytersession.Session + err error + ) + err = retry.OnError(kernelWaitingBackoff, func(err error) bool { + log.Error("failed to create context, retrying: %v", err) + return err != nil + }, func() error { + client, session, err = c.createJupyterContext(CreateContextRequest{ + Language: language, + Cwd: "", + }) + return err + }) + if err != nil { + return err + } + + c.setDefaultLanguageSession(language, session.ID) + c.jupyterClientMap.Store(session.ID, &jupyterKernel{ + kernelID: session.Kernel.ID, + client: client, + language: language, + }) + return nil +} + +// createJupyterContext performs the actual context creation workflow. +func (c *Controller) createJupyterContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) { + client := c.jupyterClient() + + kernel, err := c.searchKernel(client, request.Language) + if err != nil { + return nil, nil, err + } + + sessionID := c.newContextID() + ipynb, err := c.newIpynbPath(sessionID, request.Cwd) + if err != nil { + return nil, nil, err + } + + jupyterSession, err := client.CreateSession(sessionID, ipynb, kernel) + if err != nil { + return nil, nil, err + } + + kernels, err := client.ListKernels() + if err != nil { + return nil, nil, err + } + + found := false + for _, k := range kernels { + if k.ID == jupyterSession.Kernel.ID { + found = true + break + } + } + if !found { + return nil, nil, errors.New("kernel not found") + } + + return client, jupyterSession, nil +} + +// storeJupyterKernel caches a session -> kernel mapping. +func (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel) { + c.jupyterClientMap.Store(sessionID, kernel) +} + +func (c *Controller) jupyterClient() *jupyter.Client { + httpClient := &http.Client{ + Transport: &jupyter.AuthTransport{ + Token: c.token, + Base: http.DefaultTransport, + }, + } + + return jupyter.NewClient(c.baseURL, + jupyter.WithToken(c.token), + jupyter.WithHTTPClient(httpClient)) +} + +func (c *Controller) getDefaultLanguageSession(language Language) string { + if v, ok := c.defaultLanguageSessions.Load(language); ok { + if session, ok := v.(string); ok { + return session + } + } + return "" +} + +func (c *Controller) setDefaultLanguageSession(language Language, sessionID string) { + c.defaultLanguageSessions.Store(language, sessionID) +} + +func (c *Controller) deleteDefaultSessionByID(sessionID string) { + c.defaultLanguageSessions.Range(func(key, value any) bool { + if s, ok := value.(string); ok && s == sessionID { + c.defaultLanguageSessions.Delete(key) + } + return true + }) +} + +func (c *Controller) listAllContexts() ([]CodeContext, error) { + contexts := make([]CodeContext, 0) + seen := make(map[string]struct{}) + + c.jupyterClientMap.Range(func(key, value any) bool { + session, _ := key.(string) + if kernel, ok := value.(*jupyterKernel); ok && kernel != nil { + contexts = append(contexts, CodeContext{ID: session, Language: kernel.language}) + seen[session] = struct{}{} + } + return true + }) + + c.defaultLanguageSessions.Range(func(key, value any) bool { + lang, _ := key.(Language) + session, _ := value.(string) + if session == "" { + return true + } + // Skip if already collected from jupyterClientMap to avoid duplicates. + if _, exists := seen[session]; exists { + return true + } + contexts = append(contexts, CodeContext{ID: session, Language: lang}) + return true + }) + + return contexts, nil +} + +func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) { + contexts := make([]CodeContext, 0) + seen := make(map[string]struct{}) + + c.jupyterClientMap.Range(func(key, value any) bool { + session, _ := key.(string) + if kernel, ok := value.(*jupyterKernel); ok && kernel != nil && kernel.language == language { + contexts = append(contexts, CodeContext{ID: session, Language: language}) + seen[session] = struct{}{} + } + return true + }) + + if defaultContext := c.getDefaultLanguageSession(language); defaultContext != "" { + // Skip if already collected from jupyterClientMap to avoid duplicates. + if _, exists := seen[defaultContext]; !exists { + contexts = append(contexts, CodeContext{ID: defaultContext, Language: language}) + } + } + + return contexts, nil +} diff --git a/components/execd/pkg/runtime/context_test.go b/components/execd/pkg/runtime/context_test.go new file mode 100644 index 0000000..c951134 --- /dev/null +++ b/components/execd/pkg/runtime/context_test.go @@ -0,0 +1,166 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListContextsAndNewIpynbPath(t *testing.T) { + c := NewController("http://example", "token") + c.jupyterClientMap.Store("session-python", &jupyterKernel{language: Python}) + c.defaultLanguageSessions.Store(Go, "session-go-default") + + pyContexts, err := c.listLanguageContexts(Python) + require.NoError(t, err) + require.Len(t, pyContexts, 1) + require.Equal(t, "session-python", pyContexts[0].ID) + require.Equal(t, Python, pyContexts[0].Language) + + allContexts, err := c.listAllContexts() + require.NoError(t, err) + require.Len(t, allContexts, 2) + + tmpDir := filepath.Join(t.TempDir(), "nested") + path, err := c.newIpynbPath("abc123", tmpDir) + require.NoError(t, err) + _, statErr := os.Stat(tmpDir) + require.NoError(t, statErr, "expected directory to be created") + expected := filepath.Join(tmpDir, "abc123.ipynb") + require.Equal(t, expected, path) +} + +func TestNewContextID_UniqueAndLength(t *testing.T) { + c := NewController("", "") + id1 := c.newContextID() + id2 := c.newContextID() + + require.NotEmpty(t, id1) + require.NotEmpty(t, id2) + require.NotEqual(t, id1, id2, "expected unique ids") + require.Len(t, id1, 32) + require.Len(t, id2, 32) +} + +func TestNewIpynbPath_ErrorWhenCwdIsFile(t *testing.T) { + c := NewController("", "") + tmpFile := filepath.Join(t.TempDir(), "file.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("x"), 0o644)) + + _, err := c.newIpynbPath("abc", tmpFile) + require.Error(t, err, "expected error when cwd is a file") +} + +func TestNewIpynbPath_ExpandsHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + c := NewController("", "") + path, err := c.newIpynbPath("abc", "~/workspace") + require.NoError(t, err) + require.Equal(t, filepath.Join(home, "workspace", "abc.ipynb"), path) +} + +func TestListContextUnsupportedLanguage(t *testing.T) { + c := NewController("", "") + _, err := c.ListContext(Command.String()) + require.Error(t, err, "expected error for command language") + _, err = c.ListContext(BackgroundCommand.String()) + require.Error(t, err, "expected error for background-command language") + _, err = c.ListContext(SQL.String()) + require.Error(t, err, "expected error for sql language") +} + +func TestDeleteContext_NotFound(t *testing.T) { + c := NewController("", "") + err := c.DeleteContext("missing") + require.Error(t, err, "expected ErrContextNotFound") + require.ErrorIs(t, err, ErrContextNotFound) +} + +func TestGetContext_NotFound(t *testing.T) { + c := NewController("", "") + + _, err := c.GetContext("missing") + require.Error(t, err, "expected ErrContextNotFound") + require.ErrorIs(t, err, ErrContextNotFound) +} + +func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { + sessionID := "sess-123" + + // mock jupyter server that accepts DELETE + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method, "unexpected method") + require.True(t, strings.HasSuffix(r.URL.Path, "/api/sessions/"+sessionID), "unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := NewController(server.URL, "token") + c.jupyterClientMap.Store(sessionID, &jupyterKernel{language: Python}) + c.defaultLanguageSessions.Store(Python, sessionID) + + require.NoError(t, c.DeleteContext(sessionID)) + + require.Nil(t, c.getJupyterKernel(sessionID), "expected cache to be cleared") + _, ok := c.defaultLanguageSessions.Load(Python) + require.False(t, ok, "expected default session entry to be removed") +} + +func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { + lang := Python + session1 := "sess-1" + session2 := "sess-2" + + // mock jupyter server to accept two deletes + deleteCalls := make(map[string]int) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method, "unexpected method") + if strings.Contains(r.URL.Path, session1) { + deleteCalls[session1]++ + } else if strings.Contains(r.URL.Path, session2) { + deleteCalls[session2]++ + } else { + require.Failf(t, "unexpected path", "%s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := NewController(server.URL, "token") + c.jupyterClientMap.Store(session1, &jupyterKernel{language: lang}) + c.jupyterClientMap.Store(session2, &jupyterKernel{language: lang}) + c.defaultLanguageSessions.Store(lang, session2) + + require.NoError(t, c.DeleteLanguageContext(lang)) + + _, ok := c.jupyterClientMap.Load(session1) + require.False(t, ok, "expected session1 removed from cache") + _, ok = c.jupyterClientMap.Load(session2) + require.False(t, ok, "expected session2 removed from cache") + _, ok = c.defaultLanguageSessions.Load(lang) + require.False(t, ok, "expected default entry removed") + require.Equal(t, 1, deleteCalls[session1]) + require.Equal(t, 1, deleteCalls[session2]) +} diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go new file mode 100644 index 0000000..dca12c4 --- /dev/null +++ b/components/execd/pkg/runtime/ctrl.go @@ -0,0 +1,105 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "database/sql" + "fmt" + "sync" + "time" + + "k8s.io/apimachinery/pkg/util/wait" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter" +) + +var kernelWaitingBackoff = wait.Backoff{ + Steps: 60, + Duration: 500 * time.Millisecond, + Factor: 1.5, + Jitter: 0.1, +} + +// Controller manages code execution across runtimes. +type Controller struct { + baseURL string + token string + mu sync.RWMutex + jupyterClientMap sync.Map // map[sessionID]*jupyterKernel + defaultLanguageSessions sync.Map // map[Language]string + commandClientMap sync.Map // map[sessionID]*commandKernel + bashSessionClientMap sync.Map // map[sessionID]*bashSession + ptySessionMap sync.Map // map[sessionID]*ptySession + isolatedSessionMap sync.Map // map[sessionID]*isolatedSession + db *sql.DB + dbOnce sync.Once +} + +type jupyterKernel struct { + mu sync.Mutex + kernelID string + client *jupyter.Client + language Language +} + +type commandKernel struct { + pid int + stdoutPath string + stderrPath string + startedAt time.Time + finishedAt *time.Time + exitCode *int + errMsg string + running bool + isBackground bool + content string +} + +// NewController creates a runtime controller. +func NewController(baseURL, token string) *Controller { + return &Controller{ + baseURL: baseURL, + token: token, + } +} + +// Execute dispatches a request to the correct backend. +func (c *Controller) Execute(request *ExecuteCodeRequest) error { + var cancel context.CancelFunc + var ctx context.Context + if request.Timeout > 0 { + ctx, cancel = context.WithTimeout(context.Background(), request.Timeout) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + + switch request.Language { + case Command: + defer cancel() + return c.runCommand(ctx, request) + case BackgroundCommand: + return c.runBackgroundCommand(ctx, cancel, request) + case Bash, Python, Java, JavaScript, TypeScript, Go: + defer cancel() + return c.runJupyter(ctx, request) + case SQL: + defer cancel() + return c.runSQL(ctx, request) + default: + defer cancel() + return fmt.Errorf("unknown language: %s", request.Language) + } +} diff --git a/components/execd/pkg/runtime/env.go b/components/execd/pkg/runtime/env.go new file mode 100644 index 0000000..65e3312 --- /dev/null +++ b/components/execd/pkg/runtime/env.go @@ -0,0 +1,104 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "fmt" + "os" + "strings" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" +) + +// loadExtraEnvFromFile reads key=value lines from EXECD_ENVS (if set). +// Empty lines and lines starting with '#' are ignored. +func loadExtraEnvFromFile() map[string]string { + path := os.Getenv("EXECD_ENVS") + if path == "" { + return nil + } + resolvedPath, err := pathutil.ExpandPath(path) + if err != nil { + log.Warn("EXECD_ENVS: failed to resolve file path %s: %v", path, err) + return nil + } + + data, err := os.ReadFile(resolvedPath) + if err != nil { + log.Warn("EXECD_ENVS: failed to read file %s: %v", resolvedPath, err) + return nil + } + + envs := make(map[string]string) + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + kv := strings.SplitN(line, "=", 2) + if len(kv) != 2 { + log.Warn("EXECD_ENVS: skip malformed line: %s", line) + continue + } + envs[kv[0]] = os.ExpandEnv(kv[1]) + } + + return envs +} + +// mergeEnvs overlays extra into base and returns a merged slice. +func mergeEnvs(base []string, extra map[string]string) []string { + if len(extra) == 0 { + return base + } + + merged := make(map[string]string, len(base)+len(extra)) + for _, kv := range base { + pair := strings.SplitN(kv, "=", 2) + if len(pair) == 2 { + merged[pair[0]] = pair[1] + } + } + + for k, v := range extra { + merged[k] = v + } + + out := make([]string, 0, len(merged)) + for k, v := range merged { + out = append(out, fmt.Sprintf("%s=%s", k, v)) + } + + return out +} + +// mergeExtraEnvs merges environment maps from file and request-level overrides. +func mergeExtraEnvs(fromFile, fromRequest map[string]string) map[string]string { + if len(fromRequest) == 0 { + return fromFile + } + + merged := make(map[string]string, len(fromFile)+len(fromRequest)) + for k, v := range fromFile { + merged[k] = v + } + for k, v := range fromRequest { + merged[k] = v + } + + return merged +} diff --git a/components/execd/pkg/runtime/env_test.go b/components/execd/pkg/runtime/env_test.go new file mode 100644 index 0000000..27b5d05 --- /dev/null +++ b/components/execd/pkg/runtime/env_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoadExtraEnvFromFileUnset(t *testing.T) { + t.Setenv("EXECD_ENVS", "") + require.Nil(t, loadExtraEnvFromFile(), "expected nil when EXECD_ENVS unset") +} + +func TestLoadExtraEnvFromFileParsesAndExpands(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "env") + + t.Setenv("EXECD_ENVS", envFile) + t.Setenv("BASE_DIR", "/opt/base") + + content := strings.Join([]string{ + "# comment", + "FOO=bar", + "PATH=$BASE_DIR/bin", + "MALFORMED", + "EMPTY=", + "", + }, "\n") + + require.NoError(t, os.WriteFile(envFile, []byte(content), 0o644)) + + got := loadExtraEnvFromFile() + require.Len(t, got, 3) + require.Equal(t, "bar", got["FOO"]) + require.Equal(t, "/opt/base/bin", got["PATH"]) + val, ok := got["EMPTY"] + require.True(t, ok) + require.Equal(t, "", val) +} + +func TestLoadExtraEnvFromFileMissingFile(t *testing.T) { + dir := t.TempDir() + envFile := filepath.Join(dir, "does-not-exist") + t.Setenv("EXECD_ENVS", envFile) + + require.Nil(t, loadExtraEnvFromFile(), "expected nil for missing file") +} + +func TestLoadExtraEnvFromFileSupportsHomePath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + envFile := filepath.Join(home, "extra.env") + require.NoError(t, os.WriteFile(envFile, []byte("FOO=bar\n"), 0o644)) + t.Setenv("EXECD_ENVS", "~/extra.env") + + got := loadExtraEnvFromFile() + require.Equal(t, "bar", got["FOO"]) +} + +func TestMergeEnvsOverlaysExtra(t *testing.T) { + base := []string{"A=1", "B=2"} + extra := map[string]string{"B": "override", "C": "3"} + + merged := mergeEnvs(base, extra) + got := make(map[string]string) + for _, kv := range merged { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + got[parts[0]] = parts[1] + } + } + + require.Len(t, got, 3) + require.Equal(t, "1", got["A"]) + require.Equal(t, "override", got["B"]) + require.Equal(t, "3", got["C"]) +} + +func TestMergeExtraEnvsMergesAndOverrides(t *testing.T) { + fromFile := map[string]string{"A": "1", "B": "2"} + fromRequest := map[string]string{"B": "override", "C": "3"} + + got := mergeExtraEnvs(fromFile, fromRequest) + + require.Len(t, got, 3) + require.Equal(t, "1", got["A"]) + require.Equal(t, "override", got["B"]) + require.Equal(t, "3", got["C"]) +} + +func TestMergeExtraEnvsHandlesNilFromFile(t *testing.T) { + fromRequest := map[string]string{"ONLY": "request"} + + got := mergeExtraEnvs(nil, fromRequest) + + require.Len(t, got, 1) + require.Equal(t, "request", got["ONLY"]) +} diff --git a/components/execd/pkg/runtime/errors.go b/components/execd/pkg/runtime/errors.go new file mode 100644 index 0000000..2517167 --- /dev/null +++ b/components/execd/pkg/runtime/errors.go @@ -0,0 +1,19 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import "errors" + +var ErrContextNotFound = errors.New("context not found") diff --git a/components/execd/pkg/runtime/helpers_test.go b/components/execd/pkg/runtime/helpers_test.go new file mode 100644 index 0000000..843c567 --- /dev/null +++ b/components/execd/pkg/runtime/helpers_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type stubDriver struct { + columns []string + rows [][]driver.Value + execRowsAffected int64 + queryErr error + execErr error + pingErr error + execCalled int32 + queryCalled int32 +} + +type stubConn struct { + d *stubDriver +} + +func (c *stubConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not implemented") } +func (c *stubConn) Close() error { return nil } +func (c *stubConn) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") } + +func (c *stubConn) Ping(context.Context) error { + return c.d.pingErr +} + +func (c *stubConn) ExecContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Result, error) { + atomic.AddInt32(&c.d.execCalled, 1) + if c.d.execErr != nil { + return nil, c.d.execErr + } + return driver.RowsAffected(c.d.execRowsAffected), nil +} + +func (c *stubConn) QueryContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) { + atomic.AddInt32(&c.d.queryCalled, 1) + if c.d.queryErr != nil { + return nil, c.d.queryErr + } + return &stubRows{ + columns: c.d.columns, + rows: c.d.rows, + }, nil +} + +type stubRows struct { + columns []string + rows [][]driver.Value + idx int +} + +func (r *stubRows) Columns() []string { return r.columns } +func (r *stubRows) Close() error { return nil } +func (r *stubRows) Next(dest []driver.Value) error { + if r.idx >= len(r.rows) { + return io.EOF + } + row := r.rows[r.idx] + r.idx++ + for i, v := range row { + dest[i] = v + } + return nil +} + +type stubConnector struct { + d *stubDriver +} + +func (c *stubConnector) Connect(context.Context) (driver.Conn, error) { + return &stubConn{d: c.d}, nil +} + +func (c *stubConnector) Driver() driver.Driver { + return c +} + +func (c *stubConnector) Open(string) (driver.Conn, error) { + return &stubConn{d: c.d}, nil +} + +func newStubDB(t *testing.T, d *stubDriver) *sql.DB { + t.Helper() + driverName := fmt.Sprintf("stub-%d", time.Now().UnixNano()) + sql.Register(driverName, &stubConnector{d: d}) + db, err := sql.Open(driverName, "") + require.NoError(t, err) + return db +} diff --git a/components/execd/pkg/runtime/interrupt.go b/components/execd/pkg/runtime/interrupt.go new file mode 100644 index 0000000..a2c92d9 --- /dev/null +++ b/components/execd/pkg/runtime/interrupt.go @@ -0,0 +1,122 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "errors" + "fmt" + "syscall" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +// Interrupt stops execution in the specified session. +func (c *Controller) Interrupt(sessionID string) error { + switch { + case c.getJupyterKernel(sessionID) != nil: + kernel := c.getJupyterKernel(sessionID) + log.Warn("Interrupting Jupyter kernel %s", kernel.kernelID) + return kernel.client.InterruptKernel(kernel.kernelID) + case c.getCommandKernel(sessionID) != nil: + // Snapshot under c.mu so running/pid are observed consistently with + // markCommandFinished. killPid signals the entire process group, so + // guarding against a stale PID is critical: a late Interrupt on a + // finished session must not blast SIGTERM/SIGKILL at an unrelated + // process group that has reused the PID. + snapshot := c.commandSnapshot(sessionID) + if snapshot == nil || !snapshot.running || snapshot.pid <= 0 { + return fmt.Errorf("command session %s is not running", sessionID) + } + return c.killPid(snapshot.pid) + case c.getBashSession(sessionID) != nil: + return c.closeBashSession(sessionID) + default: + return errors.New("no such session") + } +} + +// killPid sends SIGTERM followed by SIGKILL if needed. +// +// Commands are launched with Setpgid: true, so pid is also the process group +// id. We signal the entire group via syscall.Kill(-pid, sig) so child and +// grandchild processes are terminated, not just the group leader. +// +// kill(2) on a process group only guarantees delivery to at least one +// member, and kill(-pid, 0) keeps reporting the group as observable while +// any unreaped zombie lingers. The probe loops below are therefore +// best-effort logging — once a kill signal has been delivered, a slow or +// asynchronous teardown is not treated as a hard failure that would +// surface as a 500 from Interrupt. +func (c *Controller) killPid(pid int) error { + if pid <= 0 { + return fmt.Errorf("invalid pid %d", pid) + } + log.Warn("Attempting to terminate process group %d", pid) + + sigtermDelivered := false + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + log.Warn("SIGTERM failed for pgroup %d: %v, trying SIGKILL", pid, err) + } else { + sigtermDelivered = true + // Probe the group for liveness. os.Process.Wait() doesn't apply + // because the leader is not a child of this goroutine. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if err := syscall.Kill(-pid, 0); err != nil { + if errors.Is(err, syscall.ESRCH) { + log.Info("Process group %d terminated gracefully", pid) + return nil + } + } + time.Sleep(50 * time.Millisecond) + } + log.Warn("Process group %d did not exit after SIGTERM, escalating to SIGKILL", pid) + } + + if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil + } + if sigtermDelivered { + // SIGTERM was already delivered to at least one member, so the + // kill is in flight. SIGKILL failure here is commonly EPERM on + // a group reduced to zombies — the kernel will reap them once + // the parent runs Wait(). Surface as a warning rather than a + // hard error. + log.Warn("SIGKILL on pgroup %d failed: %v; teardown likely already in progress", pid, err) + return nil + } + return fmt.Errorf("failed to kill process group %d: %w", pid, err) + } + + for range 3 { + if err := syscall.Kill(-pid, 0); err != nil { + if errors.Is(err, syscall.ESRCH) { + log.Info("Process group %d confirmed terminated", pid) + return nil + } + } + time.Sleep(50 * time.Millisecond) + } + log.Warn("Process group %d still observable after SIGKILL; teardown may complete asynchronously", pid) + return nil +} diff --git a/components/execd/pkg/runtime/interrupt_windows.go b/components/execd/pkg/runtime/interrupt_windows.go new file mode 100644 index 0000000..9cb7585 --- /dev/null +++ b/components/execd/pkg/runtime/interrupt_windows.go @@ -0,0 +1,77 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package runtime + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/internal/safego" +) + +// Interrupt stops execution in the specified session. +func (c *Controller) Interrupt(sessionID string) error { + switch { + case c.getJupyterKernel(sessionID) != nil: + kernel := c.getJupyterKernel(sessionID) + log.Warn("Interrupting Jupyter kernel %s", kernel.kernelID) + return kernel.client.InterruptKernel(kernel.kernelID) + case c.getCommandKernel(sessionID) != nil: + // Guard against a stale PID after the command has finished: the + // kernel is retained in commandClientMap, so a late Interrupt could + // otherwise terminate an unrelated process that reused the PID. + snapshot := c.commandSnapshot(sessionID) + if snapshot == nil || !snapshot.running || snapshot.pid <= 0 { + return fmt.Errorf("command session %s is not running", sessionID) + } + return c.killPid(snapshot.pid) + default: + return errors.New("no such session") + } +} + +// killPid terminates a process on Windows. +func (c *Controller) killPid(pid int) error { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + log.Warn("Attempting to terminate process %d", pid) + + if err := process.Kill(); err != nil { + return fmt.Errorf("failed to kill process %d: %w", pid, err) + } + + // Best-effort wait to reduce zombies; os.Process.Wait only works for child processes. + done := make(chan error, 1) + safego.Go(func() { + _, err := process.Wait() + done <- err + }) + + select { + case <-done: + case <-time.After(3 * time.Second): + log.Warn("Process %d kill wait timed out", pid) + } + + return nil +} diff --git a/components/execd/pkg/runtime/isolated_session.go b/components/execd/pkg/runtime/isolated_session.go new file mode 100644 index 0000000..8d2db55 --- /dev/null +++ b/components/execd/pkg/runtime/isolated_session.go @@ -0,0 +1,195 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package runtime + +import ( + "fmt" + "io" + "os/exec" + "sync" + "syscall" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" +) + +// IsolatedSessionOptions bundles the parameters for creating an isolated session. +type IsolatedSessionOptions struct { + Profile string + WorkspacePath string + WorkspaceMode string + ExtraWritable []string + Binds []isolation.BindMount + ShareNet *bool + EnvPassthroughMode string + EnvPassthroughKeys []string + Uid *uint32 + Gid *uint32 + UidMode string // "setpriv" (default) or "userns" + IdleTimeoutSeconds int +} + +// isolatedSession holds a long-running bash process inside a bwrap namespace. +type isolatedSession struct { + id string + mu sync.RWMutex + runMu sync.Mutex // serializes concurrent Run calls + opts *IsolatedSessionOptions + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + doneCh chan struct{} // closed when the bwrap process exits + upperID string // key in UpperManager, used for Release/Remove + upperDir string + workDir string + createdAt time.Time + lastRunAt time.Time + isolator isolation.Isolator +} + +func newIsolatedSession(id string, opts *IsolatedSessionOptions, iso isolation.Isolator) *isolatedSession { + return &isolatedSession{ + id: id, + opts: opts, + isolator: iso, + doneCh: make(chan struct{}), + createdAt: time.Now(), + lastRunAt: time.Now(), + } +} + +// start launches bwrap + bash inside a namespace. +func (s *isolatedSession) start() error { + cmd := exec.Command("bash", "--noprofile", "--norc") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + wrapOpts := isolation.WrapOptions{ + ExtraWritable: s.opts.ExtraWritable, + Binds: s.opts.Binds, + ShareNet: true, + } + + switch s.opts.Profile { + case string(isolation.ProfileBalanced): + wrapOpts.Profile = isolation.ProfileBalanced + case string(isolation.ProfileStrict), "": + wrapOpts.Profile = isolation.ProfileStrict + default: + return fmt.Errorf("unknown isolation profile %q", s.opts.Profile) + } + + wrapOpts.Workspace.Path = s.opts.WorkspacePath + switch isolation.WorkspaceMode(s.opts.WorkspaceMode) { + case isolation.WorkspaceRW: + wrapOpts.Workspace.Mode = isolation.WorkspaceRW + case isolation.WorkspaceRO: + wrapOpts.Workspace.Mode = isolation.WorkspaceRO + default: + wrapOpts.Workspace.Mode = isolation.WorkspaceOverlay + } + + if s.opts.ShareNet != nil { + wrapOpts.ShareNet = *s.opts.ShareNet + } + if s.opts.EnvPassthroughMode != "" { + wrapOpts.EnvPassthrough.Mode = isolation.EnvMode(s.opts.EnvPassthroughMode) + wrapOpts.EnvPassthrough.Keys = s.opts.EnvPassthroughKeys + } else { + wrapOpts.EnvPassthrough.Mode = isolation.EnvModeDeny + } + wrapOpts.Uid = s.opts.Uid + wrapOpts.Gid = s.opts.Gid + if s.opts.UidMode != "" { + wrapOpts.UidMode = isolation.UidMode(s.opts.UidMode) + } + wrapOpts.UpperDir = s.upperDir + wrapOpts.WorkDir = s.workDir + + if err := s.isolator.Wrap(cmd, wrapOpts); err != nil { + return err + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + return err + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + stdin.Close() + stdout.Close() + for _, f := range cmd.ExtraFiles { + f.Close() + } + return err + } + + for _, f := range cmd.ExtraFiles { + f.Close() + } + + s.cmd = cmd + s.stdin = stdin + s.stdout = stdout + + go func() { + _ = cmd.Wait() + close(s.doneCh) + }() + + // Brief startup check — if bwrap fails immediately (bad capabilities, + // missing binary inside namespace, etc.) we detect it here instead of + // waiting until the first Run call. + select { + case <-s.doneCh: + return fmt.Errorf("bwrap process exited immediately after start") + case <-time.After(100 * time.Millisecond): + } + + return nil +} + +// stop kills the bwrap process group and waits for process reaping. +func (s *isolatedSession) stop() error { + if s.stdin != nil { + s.stdin.Close() + } + if s.stdout != nil { + s.stdout.Close() + } + if s.cmd != nil && s.cmd.Process != nil { + _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGKILL) + // Wait for the death-watch goroutine to finish cmd.Wait(). + <-s.doneCh + } + return nil +} + +// dead returns true if the bwrap process has exited. +func (s *isolatedSession) dead() bool { + select { + case <-s.doneCh: + return true + default: + return false + } +} diff --git a/components/execd/pkg/runtime/isolated_session_ctrl.go b/components/execd/pkg/runtime/isolated_session_ctrl.go new file mode 100644 index 0000000..41c20e8 --- /dev/null +++ b/components/execd/pkg/runtime/isolated_session_ctrl.go @@ -0,0 +1,618 @@ +// Copyright 2026 Alibaba Group Holding Ltd. + +//go:build !windows + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/google/uuid" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/telemetry" + "github.com/alibaba/opensandbox/execd/pkg/vfs" +) + +const isolatedRunEndMarkerPrefix = "__ISOLATED_RUN_END__" + +// IsolatedRunner is the concrete isolated session runner. +type IsolatedRunner struct { + ctrl *Controller + isolator isolation.Isolator + upperMgr *isolation.UpperManager + allowedWritable []string + stopGC chan struct{} +} + +// NewIsolatedRunner creates the isolated session runner. +func NewIsolatedRunner(ctrl *Controller, iso isolation.Isolator, cfg isolation.Config) (*IsolatedRunner, error) { + mgr, err := isolation.NewUpperManager(cfg.UpperRoot, cfg.UpperMaxBytes) + if err != nil { + return nil, fmt.Errorf("isolated runner: upper manager: %w", err) + } + r := &IsolatedRunner{ + ctrl: ctrl, + isolator: iso, + upperMgr: mgr, + allowedWritable: cfg.AllowedWritable, + stopGC: make(chan struct{}), + } + go r.gcLoop() + + // Register with telemetry so gauges can read session/upper stats. + telemetry.SetIsolationStatsProvider(r.statsSnapshot) + + return r, nil +} + +// statsSnapshot returns current isolation stats for telemetry gauges. +func (r *IsolatedRunner) statsSnapshot() telemetry.IsolationStats { + sessionCount := int64(0) + r.ctrl.isolatedSessionMap.Range(func(_, _ any) bool { + sessionCount++ + return true + }) + usage, _ := r.upperMgr.Usage() + return telemetry.IsolationStats{ + ActiveSessions: sessionCount, + UpperUsageBytes: usage, + } +} + +// startGC begins periodic idle session cleanup. +func (r *IsolatedRunner) gcLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-r.stopGC: + return + case <-ticker.C: + r.CollectIdle() + } + } +} + +// CollectIdle scans sessions and deletes those past their idle timeout +// or whose bwrap process has died. +func (r *IsolatedRunner) CollectIdle() { + now := time.Now() + r.ctrl.isolatedSessionMap.Range(func(key, value any) bool { + s, ok := value.(*isolatedSession) + if !ok { + return true + } + + sessionID := s.id + + if s.dead() { + log.Info("idle GC: cleaning up dead session %s", sessionID) + if err := r.DeleteIsolatedSession(sessionID); err != nil { + log.Warn("idle GC: delete dead session %s: %v", sessionID, err) + } + return true + } + + s.mu.RLock() + timeout := time.Duration(s.opts.IdleTimeoutSeconds) * time.Second + idle := now.Sub(s.lastRunAt) + s.mu.RUnlock() + + if timeout > 0 && idle > timeout { + if !s.runMu.TryLock() { + return true + } + s.runMu.Unlock() + log.Info("idle GC: deleting session %s (idle %v > timeout %v)", sessionID, idle, timeout) + if err := r.DeleteIsolatedSession(sessionID); err != nil { + log.Warn("idle GC: delete session %s: %v", sessionID, err) + } + } + return true + }) +} + +// StopGC stops the background GC goroutine. +func (r *IsolatedRunner) StopGC() { + close(r.stopGC) +} + +// Available reports whether the isolator is ready. +func (r *IsolatedRunner) Available() bool { + return r.isolator.Available() +} + +// CreateIsolatedSession starts a new bwrap + bash session. +func (r *IsolatedRunner) CreateIsolatedSession(opts *IsolatedSessionOptions) (string, error) { + if err := r.validateExtraWritable(opts.ExtraWritable); err != nil { + return "", err + } + + if err := r.validateBinds(opts.Binds); err != nil { + return "", err + } + + if err := os.MkdirAll(opts.WorkspacePath, 0o755); err != nil { + return "", fmt.Errorf("create workspace: %w", err) + } + + id := uuid.New().String() + session := newIsolatedSession(id, opts, r.isolator) + + // Allocate upper directory for overlay mode. + if opts.WorkspaceMode == string(isolation.WorkspaceOverlay) || opts.WorkspaceMode == "" { + upperID, upperDir, workDir, err := r.upperMgr.Allocate() + if err != nil { + return "", fmt.Errorf("allocate upper: %w", err) + } + session.upperID = upperID + session.upperDir = upperDir + session.workDir = workDir + } + + if err := session.start(); err != nil { + if session.upperID != "" { + _ = r.upperMgr.Remove(session.upperID) + } + return "", fmt.Errorf("start bwrap: %w", err) + } + + r.ctrl.isolatedSessionMap.Store(id, session) + log.Info("created isolated session %s (profile=%s, mode=%s)", id, opts.Profile, opts.WorkspaceMode) + return id, nil +} + +// GetIsolatedSession returns session state. +func (r *IsolatedRunner) GetIsolatedSession(id string) (*IsolatedSessionState, error) { + s := r.lookup(id) + if s == nil { + return nil, ErrContextNotFound + } + + s.mu.RLock() + defer s.mu.RUnlock() + + status := SessionStatusActive + if s.dead() { + status = SessionStatusDead + } + + state := &IsolatedSessionState{ + Status: status, + CreatedAt: s.createdAt, + LastRunAt: s.lastRunAt, + } + + if s.opts.IdleTimeoutSeconds > 0 { + remaining := s.opts.IdleTimeoutSeconds - int(time.Since(s.lastRunAt).Seconds()) + if remaining < 0 { + remaining = 0 + } + state.IdleRemainingSeconds = &remaining + } + + return state, nil +} + +// Session status values. +const ( + SessionStatusActive = "active" + SessionStatusDead = "dead" +) + +// IsolatedSessionState is returned by GetIsolatedSession. +type IsolatedSessionState struct { + Status string + CreatedAt time.Time + LastRunAt time.Time + IdleRemainingSeconds *int +} + +// IsolatedSessionSummary describes a single session in a list response. +type IsolatedSessionSummary struct { + SessionID string + IsolatedSessionState +} + +// ListIsolatedSessions returns a summary of all active isolated sessions. +func (r *IsolatedRunner) ListIsolatedSessions() []IsolatedSessionSummary { + summaries := make([]IsolatedSessionSummary, 0) + r.ctrl.isolatedSessionMap.Range(func(key, value any) bool { + s, ok := value.(*isolatedSession) + if !ok { + return true + } + + s.mu.RLock() + status := SessionStatusActive + if s.dead() { + status = SessionStatusDead + } + summary := IsolatedSessionSummary{ + SessionID: s.id, + IsolatedSessionState: IsolatedSessionState{ + Status: status, + CreatedAt: s.createdAt, + LastRunAt: s.lastRunAt, + }, + } + if s.opts.IdleTimeoutSeconds > 0 { + remaining := s.opts.IdleTimeoutSeconds - int(time.Since(s.lastRunAt).Seconds()) + if remaining < 0 { + remaining = 0 + } + summary.IdleRemainingSeconds = &remaining + } + s.mu.RUnlock() + + summaries = append(summaries, summary) + return true + }) + return summaries +} + +// StdoutCallback is called for each line of stdout output during Run. +type StdoutCallback func(line string) + +// RunInIsolatedSession executes code in the session. +// Runs are serialized per session via s.runMu. +// envs are exported in the bash session before code runs. +func (r *IsolatedRunner) RunInIsolatedSession(ctx context.Context, id string, code string, envs map[string]string, onStdout StdoutCallback) error { + s := r.lookup(id) + if s == nil { + return ErrContextNotFound + } + + // Serialize concurrent runs on the same session. + s.runMu.Lock() + defer s.runMu.Unlock() + + if s.dead() { + return fmt.Errorf("session process has exited") + } + + s.mu.RLock() + stdin := s.stdin + stdout := s.stdout + s.mu.RUnlock() + + if stdin == nil || stdout == nil { + return fmt.Errorf("session not started") + } + + // Prepend env exports before user code. + runMarker := fmt.Sprintf("%s_%s", isolatedRunEndMarkerPrefix, uuid.New().String()) + + var script string + if len(envs) > 0 { + script += "(\n" + for k, v := range envs { + script += fmt.Sprintf("export %s=%s\n", shellescape(k), shellescape(v)) + } + script += code + if !strings.HasSuffix(script, "\n") { + script += "\n" + } + script += ")\n" + } else { + script += code + if !strings.HasSuffix(script, "\n") { + script += "\n" + } + } + script += fmt.Sprintf("echo %s $?\n", runMarker) + + // On timeout/cancel, send SIGINT to interrupt the running command + // without killing the persistent bash session. Closing stdin would + // terminate bash entirely. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + if s.cmd != nil && s.cmd.Process != nil { + _ = syscall.Kill(-s.cmd.Process.Pid, syscall.SIGINT) + } + case <-done: + } + }() + + if _, err := io.WriteString(stdin, script); err != nil { + return fmt.Errorf("write stdin: %w", err) + } + + exitCode, err := scanUntilMarker(ctx, stdout, runMarker, onStdout) + if err != nil { + return err + } + + s.mu.Lock() + s.lastRunAt = time.Now() + s.mu.Unlock() + + if exitCode != 0 { + return fmt.Errorf("command exited with code %d", exitCode) + } + + return nil +} + +// scanUntilMarker reads stdout lines until the end marker is found. +// Returns the exit code from the marker line. +func scanUntilMarker(ctx context.Context, stdout io.ReadCloser, runMarker string, onStdout StdoutCallback) (int, error) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + var exitCode int + markerSeen := false + scanDone := make(chan struct{}) + go func() { + defer close(scanDone) + for scanner.Scan() { + line := scanner.Text() + // The marker may appear mid-line if the previous command's + // output didn't end with a newline (e.g. cat of a file + // without trailing newline). + if idx := strings.Index(line, runMarker); idx >= 0 { + markerSeen = true + if idx > 0 && onStdout != nil { + onStdout(line[:idx]) + } + markerPart := line[idx:] + parts := strings.Fields(markerPart) + if len(parts) >= 2 { + if code, convErr := strconv.Atoi(parts[1]); convErr == nil { + exitCode = code + } + } + return + } + if onStdout != nil { + onStdout(line) + } + } + }() + + select { + case <-scanDone: + case <-ctx.Done(): + // Wait for scanner goroutine to finish so it doesn't consume the + // next run's output on the shared stdout pipe. + <-scanDone + return 0, ctx.Err() + } + + if err := scanner.Err(); err != nil { + return 0, fmt.Errorf("read stdout: %w", err) + } + if !markerSeen { + return 1, fmt.Errorf("session process exited without end marker (process may have died or called exit)") + } + return exitCode, nil +} + +// DeleteIsolatedSession destroys the session. +func (r *IsolatedRunner) DeleteIsolatedSession(id string) error { + s := r.lookup(id) + if s == nil { + return ErrContextNotFound + } + + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.stop(); err != nil { + log.Warn("stop isolated session %s: %v", id, err) + } + + if s.upperID != "" { + if err := r.upperMgr.Remove(s.upperID); err != nil { + log.Warn("remove upper dir for session %s: %v", id, err) + } + } + + r.ctrl.isolatedSessionMap.Delete(id) + log.Info("deleted isolated session %s", id) + return nil +} + +// DiffUpper returns an error (Phase 2). +func (r *IsolatedRunner) DiffUpper(id string, w io.Writer) error { + return fmt.Errorf("diff not implemented yet") +} + +// CommitUpper returns an error (Phase 2). +func (r *IsolatedRunner) CommitUpper(id string) error { + return fmt.Errorf("commit not implemented yet") +} + +// GetMergedView returns a VFS for the session's filesystem. +func (r *IsolatedRunner) GetMergedView(id string) (vfs.FS, error) { + s := r.lookup(id) + if s == nil { + return nil, ErrContextNotFound + } + + s.mu.RLock() + defer s.mu.RUnlock() + + // MergedView chowns files on the host side (execd's namespace). + // In setpriv mode the requested uid/gid are real host IDs, so use them. + // In userns mode the requested uid/gid are in-namespace IDs mapped to + // execd's real host uid/gid, so host-side files must use execd's own + // host uid/gid — chowning to the in-namespace ID would fail with EPERM + // (unprivileged execd) or create files that appear as nobody/overflow + // inside the sandbox. + uid := uint32(os.Getuid()) + gid := uint32(os.Getgid()) + if isolation.UidMode(s.opts.UidMode) != isolation.UidModeUserns { + if s.opts.Uid != nil { + uid = *s.opts.Uid + } + if s.opts.Gid != nil { + gid = *s.opts.Gid + } + } + + mode := isolation.WorkspaceOverlay + upper := s.upperDir + switch isolation.WorkspaceMode(s.opts.WorkspaceMode) { + case isolation.WorkspaceRW: + mode = isolation.WorkspaceRW + upper = s.opts.WorkspacePath // writes go directly to workspace + case isolation.WorkspaceRO: + mode = isolation.WorkspaceRO + } + + return isolation.NewMergedView(s.opts.WorkspacePath, upper, mode, uid, gid), nil +} + +// Capabilities returns the current isolator capabilities. +func (r *IsolatedRunner) Capabilities() isolation.Capabilities { + return r.isolator.Capabilities() +} + +func (r *IsolatedRunner) lookup(id string) *isolatedSession { + v, ok := r.ctrl.isolatedSessionMap.Load(id) + if !ok { + return nil + } + s, ok := v.(*isolatedSession) + if !ok { + return nil + } + return s +} + +func (r *IsolatedRunner) validateExtraWritable(paths []string) error { + if len(paths) == 0 { + return nil + } + if len(r.allowedWritable) == 0 { + return fmt.Errorf("extra_writable not allowed: no paths in allowlist") + } + for i := range paths { + resolved, err := r.resolveAllowedSource(paths[i]) + if err != nil { + return fmt.Errorf("extra_writable path %q: %w", paths[i], err) + } + // Mount the fully-resolved path so validation and mount target agree. + paths[i] = resolved + } + return nil +} + +// resolveAllowedSource requires src to exist, fully resolves symlinks, checks +// the resolved real path against the writable allowlist, and returns it. It is +// shared by extra_writable and binds so both enforce identical semantics: +// - the source must already exist (bwrap --bind requires this anyway), and +// - the allowlist is enforced against the fully-resolved real path, leaving +// no unresolved suffix that could be swapped to an out-of-allowlist symlink +// between validation and bwrap start. +func (r *IsolatedRunner) resolveAllowedSource(src string) (string, error) { + if src == "" { + return "", fmt.Errorf("source is required") + } + resolved, err := filepath.EvalSymlinks(filepath.Clean(src)) + if err != nil { + return "", fmt.Errorf("must be an existing path: %w", err) + } + if !r.pathAllowedResolved(resolved) { + return "", fmt.Errorf("not in allowlist") + } + return resolved, nil +} + +// validateBinds checks that every bind's source path falls within the writable +// allowlist. Read-only binds are validated too, so read access to arbitrary host +// paths outside the allowlist is not possible. +// +// The source of each bind must already exist and is fully resolved via +// filepath.EvalSymlinks; the resolved real path is written back in place. This +// enforces the allowlist against the real target and closes the TOCTOU window: +// bwrap is handed a fully-resolved path with no unresolved suffix, so a symlink +// created or swapped between validation and bwrap start cannot redirect the +// mount outside the allowlist. (bwrap's --bind requires the source to exist, so +// this adds no functional restriction.) +func (r *IsolatedRunner) validateBinds(binds []isolation.BindMount) error { + if len(binds) == 0 { + return nil + } + if len(r.allowedWritable) == 0 { + return fmt.Errorf("binds not allowed: no paths in allowlist") + } + for i := range binds { + resolved, err := r.resolveAllowedSource(binds[i].Source) + if err != nil { + return fmt.Errorf("binds source %q: %w", binds[i].Source, err) + } + // Mount the fully-resolved path so validation and mount target agree. + binds[i].Source = resolved + } + return nil +} + +// pathAllowedResolved reports whether an already symlink-resolved path is equal +// to, or nested under, any allowlist entry. Allowlist entries are themselves +// symlink-resolved so the comparison is between real paths on both sides. +func (r *IsolatedRunner) pathAllowedResolved(resolved string) bool { + for _, allowed := range r.allowedWritable { + allowedClean := resolveSymlinks(filepath.Clean(allowed)) + if resolved == allowedClean || strings.HasPrefix(resolved, allowedClean+"/") { + return true + } + } + return false +} + +// resolveSymlinks returns the real path of p with symlinks resolved. Because p +// (or a leading component) may not exist yet, it resolves the longest existing +// prefix and re-appends the remaining components, so a symlinked ancestor is +// still followed while a not-yet-created leaf is preserved. +func resolveSymlinks(p string) string { + if p == "" { + return p + } + remaining := "" + cur := p + for { + if resolved, err := filepath.EvalSymlinks(cur); err == nil { + return filepath.Clean(filepath.Join(resolved, remaining)) + } + parent := filepath.Dir(cur) + if parent == cur { + // Reached the root without an existing prefix; fall back to lexical. + return p + } + remaining = filepath.Join(filepath.Base(cur), remaining) + cur = parent + } +} + +// shellescape wraps s in single quotes, escaping embedded single quotes. +func shellescape(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} diff --git a/components/execd/pkg/runtime/isolated_session_stub.go b/components/execd/pkg/runtime/isolated_session_stub.go new file mode 100644 index 0000000..8518576 --- /dev/null +++ b/components/execd/pkg/runtime/isolated_session_stub.go @@ -0,0 +1,118 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package runtime + +import ( + "context" + "io" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/vfs" +) + +// IsolatedSessionOptions bundles creation parameters (Windows stub). +type IsolatedSessionOptions struct { + Profile string + WorkspacePath string + WorkspaceMode string + ExtraWritable []string + Binds []isolation.BindMount + ShareNet *bool + EnvPassthroughMode string + EnvPassthroughKeys []string + Uid *uint32 + Gid *uint32 + UidMode string + IdleTimeoutSeconds int +} + +// StdoutCallback is called per line of stdout (Windows stub). +type StdoutCallback func(line string) + +// IsolatedRunner is the isolated session runner (Windows stub). +type IsolatedRunner struct{} + +// NewIsolatedRunner returns nil on Windows (isolation not supported). +func NewIsolatedRunner(_ *Controller, _ isolation.Isolator, _ isolation.Config) (*IsolatedRunner, error) { + return &IsolatedRunner{}, nil +} + +// StopGC is a no-op on Windows. +func (r *IsolatedRunner) StopGC() {} + +// Available reports false on Windows. +func (r *IsolatedRunner) Available() bool { return false } + +// CreateIsolatedSession returns an error on Windows. +func (r *IsolatedRunner) CreateIsolatedSession(_ *IsolatedSessionOptions) (string, error) { + return "", ErrContextNotFound +} + +// GetIsolatedSession returns an error on Windows. +func (r *IsolatedRunner) GetIsolatedSession(_ string) (*IsolatedSessionState, error) { + return nil, ErrContextNotFound +} + +// ListIsolatedSessions returns an empty list on Windows. +func (r *IsolatedRunner) ListIsolatedSessions() []IsolatedSessionSummary { + return []IsolatedSessionSummary{} +} + +// RunInIsolatedSession returns an error on Windows. +func (r *IsolatedRunner) RunInIsolatedSession(_ context.Context, _ string, _ string, _ map[string]string, _ StdoutCallback) error { + return ErrContextNotFound +} + +// DeleteIsolatedSession returns an error on Windows. +func (r *IsolatedRunner) DeleteIsolatedSession(_ string) error { + return ErrContextNotFound +} + +// DiffUpper returns an error on Windows. +func (r *IsolatedRunner) DiffUpper(_ string, _ io.Writer) error { + return nil +} + +// CommitUpper returns an error on Windows. +func (r *IsolatedRunner) CommitUpper(_ string) error { + return nil +} + +// GetMergedView returns an error on Windows. +func (r *IsolatedRunner) GetMergedView(_ string) (vfs.FS, error) { + return nil, ErrContextNotFound +} + +// Capabilities returns empty capabilities on Windows. +func (r *IsolatedRunner) Capabilities() isolation.Capabilities { + return isolation.Capabilities{Available: false} +} + +// IsolatedSessionState is the session state (Windows stub). +type IsolatedSessionState struct { + Status string + CreatedAt time.Time + LastRunAt time.Time + IdleRemainingSeconds *int +} + +// IsolatedSessionSummary describes a session in a list response (Windows stub). +type IsolatedSessionSummary struct { + SessionID string + IsolatedSessionState +} diff --git a/components/execd/pkg/runtime/isolated_session_test.go b/components/execd/pkg/runtime/isolated_session_test.go new file mode 100644 index 0000000..ee6cc9a --- /dev/null +++ b/components/execd/pkg/runtime/isolated_session_test.go @@ -0,0 +1,478 @@ +// Copyright 2026 Alibaba Group Holding Ltd. + +//go:build !windows + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" +) + +// stubIsolator returns Available=true but Wrap is a no-op (for happy-path tests). +type stubIsolator struct { + available bool + caps isolation.Capabilities +} + +func (s *stubIsolator) Name() string { return "stub" } +func (s *stubIsolator) Available() bool { return s.available } +func (s *stubIsolator) Capabilities() isolation.Capabilities { return s.caps } +func (s *stubIsolator) Wrap(_ *exec.Cmd, _ isolation.WrapOptions) error { return nil } + +func newStubIsolator() *stubIsolator { + return &stubIsolator{ + available: true, + caps: isolation.Capabilities{ + Available: true, + Isolator: "stub", + CommitSupported: false, + DiffSupported: false, + }, + } +} + +func newTestRunner(t *testing.T) *IsolatedRunner { + t.Helper() + ctrl := NewController("", "") + mgr, err := isolation.NewUpperManager(t.TempDir(), 8<<30) + if err != nil { + t.Fatal(err) + } + return &IsolatedRunner{ + ctrl: ctrl, + isolator: newStubIsolator(), + upperMgr: mgr, + } +} + +func TestNewIsolatedRunner(t *testing.T) { + runner := newTestRunner(t) + if runner == nil { + t.Fatal("runner is nil") + } + if !runner.Available() { + t.Error("runner should be available with stub isolator") + } +} + +func TestCreateIsolatedSession_HappyPath(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: filepath.Join(t.TempDir(), "workspace"), + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatalf("CreateIsolatedSession: %v", err) + } + if id == "" { + t.Error("expected non-empty session ID") + } + + // Verify session is tracked. + s := runner.lookup(id) + if s == nil { + t.Fatal("session not found after create") + } + if s.opts.Profile != "strict" { + t.Errorf("profile = %q, want strict", s.opts.Profile) + } + + // Clean up. + if err := runner.DeleteIsolatedSession(id); err != nil { + t.Errorf("DeleteIsolatedSession: %v", err) + } +} + +func TestGetIsolatedSession_NotFound(t *testing.T) { + runner := newTestRunner(t) + _, err := runner.GetIsolatedSession("nonexistent") + if err != ErrContextNotFound { + t.Errorf("expected ErrContextNotFound, got %v", err) + } +} + +func TestGetIsolatedSession_Found(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + Profile: "balanced", + WorkspacePath: filepath.Join(t.TempDir(), "ws"), + WorkspaceMode: "overlay", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + + state, err := runner.GetIsolatedSession(id) + if err != nil { + t.Fatal(err) + } + if state.Status != "active" { + t.Errorf("status = %q, want active", state.Status) + } + if state.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } + + runner.DeleteIsolatedSession(id) +} + +func TestDeleteIsolatedSession_NotFound(t *testing.T) { + runner := newTestRunner(t) + err := runner.DeleteIsolatedSession("nonexistent") + if err != ErrContextNotFound { + t.Errorf("expected ErrContextNotFound, got %v", err) + } +} + +func TestDeleteIsolatedSession_Success(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + WorkspacePath: "/tmp", + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + + if err := runner.DeleteIsolatedSession(id); err != nil { + t.Fatal(err) + } + + // Verify removed. + if s := runner.lookup(id); s != nil { + t.Error("session should be removed after delete") + } +} + +func TestRunInIsolatedSession_HappyPath(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + Profile: "strict", + WorkspacePath: filepath.Join(t.TempDir(), "workspace"), + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // echo should succeed (exit 0). + err = runner.RunInIsolatedSession(ctx, id, "echo hello", nil, nil) + if err != nil { + t.Errorf("RunInIsolatedSession: %v", err) + } + + // Verify lastRunAt was updated. + s := runner.lookup(id) + if s == nil { + t.Fatal("session disappeared") + } + if s.lastRunAt.Before(s.createdAt) { + t.Error("lastRunAt should be >= createdAt after run") + } +} + +func TestRunInIsolatedSession_NotFound(t *testing.T) { + runner := newTestRunner(t) + ctx := context.Background() + err := runner.RunInIsolatedSession(ctx, "nonexistent", "echo hi", nil, nil) + if err != ErrContextNotFound { + t.Errorf("expected ErrContextNotFound, got %v", err) + } +} + +func TestRunInIsolatedSession_ExitCode(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + WorkspacePath: "/tmp", + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // bash -c 'exit 42' produces exit code 42 without killing the session. + err = runner.RunInIsolatedSession(ctx, id, "bash -c 'exit 42'", nil, nil) + if err == nil { + t.Error("expected error for non-zero exit code") + } +} + +func TestCapabilities(t *testing.T) { + runner := newTestRunner(t) + caps := runner.Capabilities() + if !caps.Available { + t.Error("caps.Available should be true") + } + if caps.Isolator != "stub" { + t.Errorf("Isolator = %q, want stub", caps.Isolator) + } +} + +func TestIsolatedSessionOptions_Defaults(t *testing.T) { + opts := &IsolatedSessionOptions{ + WorkspacePath: "/ws", + } + if opts.Profile != "" { + t.Error("Profile should default to empty (controller sets strict)") + } + if opts.WorkspaceMode != "" { + t.Error("WorkspaceMode should default to empty (controller sets overlay)") + } + if opts.ShareNet != nil { + t.Error("ShareNet should default to nil (start defaults to true)") + } +} + +func TestRunInIsolatedSession_StdoutCallback(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + WorkspacePath: "/tmp", + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var lines []string + onStdout := func(line string) { + lines = append(lines, line) + } + + err = runner.RunInIsolatedSession(ctx, id, "echo hello", nil, onStdout) + if err != nil { + t.Fatalf("RunInIsolatedSession: %v", err) + } + + if len(lines) != 1 || lines[0] != "hello" { + t.Errorf("expected [hello], got %v", lines) + } +} + +func TestRunInIsolatedSession_MultiLine(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + WorkspacePath: "/tmp", + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var lines []string + onStdout := func(line string) { + lines = append(lines, line) + } + + code := "echo one\necho two\necho three" + err = runner.RunInIsolatedSession(ctx, id, code, nil, onStdout) + if err != nil { + t.Fatalf("RunInIsolatedSession: %v", err) + } + + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) + } + for i, want := range []string{"one", "two", "three"} { + if lines[i] != want { + t.Errorf("line[%d] = %q, want %q", i, lines[i], want) + } + } +} + +func TestRunInIsolatedSession_EnvPersistence(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + WorkspacePath: "/tmp", + WorkspaceMode: "rw", + } + + id, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Run 1: set env var in bash session. + err = runner.RunInIsolatedSession(ctx, id, "export MY_VAR=hello_from_session", nil, nil) + if err != nil { + t.Fatalf("run 1: %v", err) + } + + // Run 2: echo the env var to verify persistence. + var lines []string + onStdout := func(line string) { + lines = append(lines, line) + } + err = runner.RunInIsolatedSession(ctx, id, "echo $MY_VAR", nil, onStdout) + if err != nil { + t.Fatalf("run 2: %v", err) + } + + if len(lines) != 1 || lines[0] != "hello_from_session" { + t.Errorf("env not persisted: got %v", lines) + } +} + +func TestRunInIsolatedSession_ConcurrentSessions(t *testing.T) { + runner := newTestRunner(t) + + opts := &IsolatedSessionOptions{ + WorkspacePath: "/tmp", + WorkspaceMode: "rw", + } + + id1, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id1) + + id2, err := runner.CreateIsolatedSession(opts) + if err != nil { + t.Fatal(err) + } + defer runner.DeleteIsolatedSession(id2) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Set different env vars in each session. + runner.RunInIsolatedSession(ctx, id1, "export SESSION=one", nil, nil) + runner.RunInIsolatedSession(ctx, id2, "export SESSION=two", nil, nil) + + // Read back — each session should have its own value. + var out1, out2 []string + runner.RunInIsolatedSession(ctx, id1, "echo $SESSION", nil, func(l string) { out1 = append(out1, l) }) + runner.RunInIsolatedSession(ctx, id2, "echo $SESSION", nil, func(l string) { out2 = append(out2, l) }) + + if len(out1) != 1 || out1[0] != "one" { + t.Errorf("session 1: expected [one], got %v", out1) + } + if len(out2) != 1 || out2[0] != "two" { + t.Errorf("session 2: expected [two], got %v", out2) + } +} + +// TestValidateBinds_SymlinkBypass verifies that a symlink placed inside an +// allowed directory cannot be used to smuggle a bind source whose real target +// lies outside the allowlist. +func TestValidateBinds_SymlinkBypass(t *testing.T) { + allowed := t.TempDir() + outside := t.TempDir() + + // allowed/link -> outside (a directory outside the allowlist). + link := filepath.Join(allowed, "link") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + + r := &IsolatedRunner{allowedWritable: []string{allowed}} + + // A direct path under the allowlist is fine. + if err := r.validateBinds([]isolation.BindMount{{Source: allowed}}); err != nil { + t.Errorf("direct allowlisted source should be accepted: %v", err) + } + + // The symlink resolves outside the allowlist and must be rejected. + err := r.validateBinds([]isolation.BindMount{{Source: link}}) + if err == nil { + t.Fatal("expected symlinked source resolving outside allowlist to be rejected") + } + if !strings.Contains(err.Error(), "not in allowlist") { + t.Errorf("expected allowlist rejection, got: %v", err) + } + + // A symlink whose target stays inside the allowlist is still accepted, and + // the source is rewritten to the resolved real path so bwrap mounts the + // resolved target (closing the TOCTOU window). + innerTarget := filepath.Join(allowed, "real") + if err := os.Mkdir(innerTarget, 0o755); err != nil { + t.Fatal(err) + } + innerLink := filepath.Join(allowed, "inner") + if err := os.Symlink(innerTarget, innerLink); err != nil { + t.Fatal(err) + } + binds := []isolation.BindMount{{Source: innerLink}} + if err := r.validateBinds(binds); err != nil { + t.Errorf("symlink resolving inside allowlist should be accepted: %v", err) + } + wantResolved, _ := filepath.EvalSymlinks(innerLink) + if binds[0].Source != wantResolved { + t.Errorf("bind source should be rewritten to resolved path %q, got %q", wantResolved, binds[0].Source) + } + + // A non-existent source under the allowlist must be rejected: a missing + // leaf could otherwise be swapped to an out-of-allowlist symlink between + // validation and bwrap start. + missing := filepath.Join(allowed, "does-not-exist-yet") + err = r.validateBinds([]isolation.BindMount{{Source: missing}}) + if err == nil { + t.Fatal("expected non-existent bind source to be rejected") + } + if !strings.Contains(err.Error(), "must be an existing path") { + t.Errorf("expected existing-path rejection, got: %v", err) + } +} diff --git a/components/execd/pkg/runtime/jupyter.go b/components/execd/pkg/runtime/jupyter.go new file mode 100644 index 0000000..6b6b9c2 --- /dev/null +++ b/components/execd/pkg/runtime/jupyter.go @@ -0,0 +1,168 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "errors" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +// runJupyter executes code through a Jupyter kernel. +func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest) error { + if c.baseURL == "" || c.token == "" { + return errors.New("language runtime server not configured, please check your image runtime") + } + if request.Context == "" { + if c.getDefaultLanguageSession(request.Language) == "" { + if err := c.createDefaultLanguageJupyterContext(request.Language); err != nil { + return err + } + } + } + + var targetSessionID string + if request.Context == "" { + targetSessionID = c.getDefaultLanguageSession(request.Language) + } else { + targetSessionID = request.Context + } + + kernel := c.getJupyterKernel(targetSessionID) + if kernel == nil { + return ErrContextNotFound + } + + request.SetDefaultHooks() + request.Hooks.OnExecuteInit(targetSessionID) + + return c.runJupyterCode(ctx, kernel, request) +} + +// runJupyterCode streams execution results for a single kernel. +// +//nolint:gocognit // complex due to hook handling; refactor later +func (c *Controller) runJupyterCode(ctx context.Context, kernel *jupyterKernel, request *ExecuteCodeRequest) error { + if !kernel.mu.TryLock() { + return errors.New("session is busy") + } + defer kernel.mu.Unlock() + + err := kernel.client.ConnectToKernel(kernel.kernelID) + if err != nil { + return err + } + defer kernel.client.DisconnectFromKernel() + + results := make(chan *execute.ExecutionResult, 10) + + err = kernel.client.ExecuteCodeStream(kernel.kernelID, request.Code, results) + if err != nil { + return err + } + + for { + select { + case result := <-results: + if result == nil { + return nil + } + dispatchExecutionResultHooks(request, result) + + case <-ctx.Done(): + log.Warn("context cancelled, try to interrupt kernel") + err = kernel.client.InterruptKernel(kernel.kernelID) + if err != nil { + log.Error("interrupt kernel failed: %v", err) + } + + request.Hooks.OnExecuteError(&execute.ErrorOutput{ + EName: "ContextCancelled", + EValue: "Interrupt kernel", + }) + return errors.New("context cancelled, interrupt kernel") + } + } +} + +func dispatchExecutionResultHooks(request *ExecuteCodeRequest, result *execute.ExecutionResult) { + if result.ExecutionCount > 0 || len(result.ExecutionData) > 0 { + request.Hooks.OnExecuteResult(result.ExecutionData, result.ExecutionCount) + } + + if result.Status != "" { + request.Hooks.OnExecuteStatus(result.Status) + } + + if result.Error != nil { + request.Hooks.OnExecuteError(result.Error) + } + + // Treat completion as success-only terminal signal. For failed executions, + // error should be the terminal event to avoid losing error delivery. + if result.ExecutionTime > 0 && result.Error == nil { + request.Hooks.OnExecuteComplete(result.ExecutionTime) + } + + for _, stream := range result.Stream { + switch stream.Name { + case execute.StreamStdout: + request.Hooks.OnExecuteStdout(stream.Text) + case execute.StreamStderr: + request.Hooks.OnExecuteStderr(stream.Text) + } + } +} + +// getJupyterKernel retrieves a kernel connection from the session map. +func (c *Controller) getJupyterKernel(sessionID string) *jupyterKernel { + if v, ok := c.jupyterClientMap.Load(sessionID); ok { + if kernel, ok := v.(*jupyterKernel); ok { + return kernel + } + } + return nil +} + +// searchKernel finds a kernel spec name for the given language. +func (c *Controller) searchKernel(client *jupyter.Client, language Language) (string, error) { + specs, err := client.GetKernelSpecs() + if err != nil { + return "", err + } + + if len(specs.Kernelspecs) == 0 { + return "", errors.New("no kernel specs found") + } + + var kernelName string + for name, spec := range specs.Kernelspecs { + if name == "python3" { + continue + } + + if spec.Spec.Language == language.String() { + kernelName = name + } + } + if kernelName == "" { + return "", errors.New("no kernel specs found") + } + + return kernelName, nil +} diff --git a/components/execd/pkg/runtime/jupyter_hooks_test.go b/components/execd/pkg/runtime/jupyter_hooks_test.go new file mode 100644 index 0000000..50acaf7 --- /dev/null +++ b/components/execd/pkg/runtime/jupyter_hooks_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" +) + +func TestDispatchExecutionResultHooks_ErrorSkipsComplete(t *testing.T) { + var ( + errorCalls int + completeCalls int + ) + + req := &ExecuteCodeRequest{ + Hooks: ExecuteResultHook{ + OnExecuteError: func(_ *execute.ErrorOutput) { + errorCalls++ + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalls++ + }, + }, + } + + dispatchExecutionResultHooks(req, &execute.ExecutionResult{ + ExecutionTime: 35 * time.Millisecond, + Error: &execute.ErrorOutput{ + EName: "RuntimeError", + EValue: "boom", + }, + }) + + require.Equal(t, 1, errorCalls) + require.Equal(t, 0, completeCalls) +} + +func TestDispatchExecutionResultHooks_SuccessEmitsComplete(t *testing.T) { + var ( + errorCalls int + completeCalls int + ) + + req := &ExecuteCodeRequest{ + Hooks: ExecuteResultHook{ + OnExecuteError: func(_ *execute.ErrorOutput) { + errorCalls++ + }, + OnExecuteComplete: func(_ time.Duration) { + completeCalls++ + }, + }, + } + + dispatchExecutionResultHooks(req, &execute.ExecutionResult{ + ExecutionTime: 50 * time.Millisecond, + }) + + require.Equal(t, 0, errorCalls) + require.Equal(t, 1, completeCalls) +} diff --git a/components/execd/pkg/runtime/language.go b/components/execd/pkg/runtime/language.go new file mode 100644 index 0000000..7e440d4 --- /dev/null +++ b/components/execd/pkg/runtime/language.go @@ -0,0 +1,35 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +// Language represents the programming language or execution mode +type Language string + +const ( + Command Language = "command" + Bash Language = "bash" + Python Language = "python" + Java Language = "java" + JavaScript Language = "javascript" + TypeScript Language = "typescript" + Go Language = "go" + SQL Language = "sql" + BackgroundCommand Language = "background-command" +) + +// String returns the string representation of the language +func (l Language) String() string { + return string(l) +} diff --git a/components/execd/pkg/runtime/pty_session.go b/components/execd/pkg/runtime/pty_session.go new file mode 100644 index 0000000..17e71fb --- /dev/null +++ b/components/execd/pkg/runtime/pty_session.go @@ -0,0 +1,678 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/alibaba/opensandbox/internal/safego" + "github.com/creack/pty" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" +) + +// PTYSession is the public interface for an interactive PTY/pipe session. +// The concrete implementation (*ptySession) is unexported; callers outside +// this package must use this interface. +type PTYSession interface { + LockWS() bool + UnlockWS() + TakeoverWS(timeout time.Duration) bool + SetEvictHandler(fn func()) uint64 + ClearEvictHandler(gen uint64) + IsRunning() bool + IsPTY() bool + ExitCode() int + Done() <-chan struct{} + StartPTY() error + StartPipe() error + WriteStdin(p []byte) (int, error) + AttachOutput() (io.Reader, io.Reader, func()) + AttachOutputWithSnapshot(since int64) (io.Reader, io.Reader, func(), []byte, int64) + SendSignal(name string) + ResizePTY(cols, rows uint16) error +} + +// IsPTYSessionSupported reports whether PTY sessions are supported on this platform. +func IsPTYSessionSupported() bool { return true } + +func NewPTYSessionID() string { + return uuidString() +} + +// ptySession manages a single interactive PTY or pipe-mode bash process. +// +// Lifecycle: +// 1. Create via newPTYSession. +// 2. Call StartPTY() or StartPipe() from the WS handler (after LockWS). +// 3. Zero or more clients call AttachOutput() to receive live output. +// 4. The bash process exits → Done() closes → exit frame sent. +// 5. Call close() to terminate an early session and release resources. +type ptySession struct { + id string + cwd string + command string // optional custom command; defaults to bash if empty + + mu sync.Mutex + closing bool + + // Process tracking (guarded by mu) + pid int // PID of the running bash process (0 = not running) + lastExitCode int // exit code; -1 until process exits + doneCh chan struct{} // closed when process exits (non-nil after Start*) + + // Stdin (PTY master in PTY mode; write end of os.Pipe in pipe mode) + stdin io.WriteCloser + + // PTY-specific + isPTY bool + ptmx *os.File // PTY master fd; nil in pipe mode + + // Replay + replay *replayBuffer + + // WS exclusive lock: only one WebSocket client at a time. + wsConnected atomic.Bool + + // Eviction hook for session takeover. The active WS handler registers a function + // that closes its own connection (and stops its pumps) so a newer client can take + // over the session. Guarded by evictMu; evictGen lets a handler clear only its own + // hook and never a successor's (see SetEvictHandler / ClearEvictHandler). + evictMu sync.Mutex + evict func() + evictGen uint64 + + // Output broadcast (guards stdoutW / stderrW). + // The broadcast goroutine holds outMu only while reading the pointer; writes + // to the pipe happen outside the lock to avoid blocking broadcast on slow clients. + outMu sync.Mutex + stdoutW *io.PipeWriter // current per-connection sink; nil when no client attached + stderrW *io.PipeWriter // nil in PTY mode +} + +func newPTYSession(id, cwd, command string) *ptySession { + return &ptySession{ + id: id, + cwd: cwd, + command: command, + replay: newReplayBuffer(), + lastExitCode: -1, + } +} + +// LockWS attempts to acquire the exclusive WebSocket connection lock. +// Returns true on success, false if another client is already connected. +func (s *ptySession) LockWS() bool { + return s.wsConnected.CompareAndSwap(false, true) +} + +// UnlockWS releases the WebSocket connection lock. +func (s *ptySession) UnlockWS() { + s.wsConnected.Store(false) +} + +// SetEvictHandler registers fn as the current connection's eviction hook and returns +// a generation token. A newer handler calling this overwrites the previous hook. Pass +// the returned token to ClearEvictHandler on teardown. +func (s *ptySession) SetEvictHandler(fn func()) uint64 { + s.evictMu.Lock() + defer s.evictMu.Unlock() + s.evictGen++ + s.evict = fn + return s.evictGen +} + +// ClearEvictHandler removes the eviction hook only if it still belongs to gen, so a +// handler tearing down never clears a successor's hook (which would race after a +// takeover hands the session to a new connection). +func (s *ptySession) ClearEvictHandler(gen uint64) { + s.evictMu.Lock() + defer s.evictMu.Unlock() + if s.evictGen == gen { + s.evict = nil + } +} + +// triggerEvict invokes the current eviction hook, if any. The hook is expected to be +// idempotent (closing an already-closed WS is a no-op), so repeated calls are safe. +func (s *ptySession) triggerEvict() { + s.evictMu.Lock() + fn := s.evict + s.evictMu.Unlock() + if fn != nil { + fn() + } +} + +// TakeoverWS forcibly acquires the WS lock for a new client. It repeatedly evicts the +// current holder (closing its WS) and retries LockWS until it wins or timeout elapses. +// The shell process keeps running throughout; the new client reattaches with replay. +// Returns true if the lock was acquired. +func (s *ptySession) TakeoverWS(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + if s.LockWS() { + return true + } + s.triggerEvict() + if time.Now().After(deadline) { + // One last attempt in case the holder released just now. + return s.LockWS() + } + time.Sleep(10 * time.Millisecond) + } +} + +// IsRunning returns true if the bash process is currently alive. +func (s *ptySession) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.pid != 0 +} + +// IsPTY returns true when the session was started in PTY mode. +func (s *ptySession) IsPTY() bool { + return s.isPTY +} + +// ExitCode returns the exit code of the last process, or -1 if it has not exited yet. +func (s *ptySession) ExitCode() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastExitCode +} + +// Done returns a channel that is closed when the bash process exits. +// Returns nil if the process has not been started yet. +func (s *ptySession) Done() <-chan struct{} { + s.mu.Lock() + defer s.mu.Unlock() + return s.doneCh +} + +// ReplayBuffer returns the session's replay buffer (thread-safe). +func (s *ptySession) ReplayBuffer() *replayBuffer { + return s.replay +} + +// StartPTY launches bash via pty.StartWithSize. +// Must be called with the WS lock held. +func (s *ptySession) StartPTY() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.pid != 0 { + return errors.New("pty session already started") + } + if s.closing { + return errors.New("pty session is closing") + } + + cmdArgs := []string{"--norc", "--noprofile"} + if s.command != "" { + cmdArgs = append(cmdArgs, "-c", s.command) + } + cmd := exec.Command("bash", cmdArgs...) + cmd.Env = os.Environ() + if s.cwd != "" { + cmd.Dir = s.cwd + } + // Do NOT set Setpgid: pty.StartWithSize sets Setsid+Setctty internally. + // Combining Setsid+Setpgid causes EPERM (setpgid is illegal for a session leader). + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: 80, Rows: 24}) + if err != nil { + return fmt.Errorf("pty.StartWithSize: %w", err) + } + + s.ptmx = ptmx + s.isPTY = true + s.pid = cmd.Process.Pid + s.doneCh = make(chan struct{}) + s.stdin = ptmx // write to the PTY master to feed stdin + + safego.Go(func() { s.broadcastPTY() }) + safego.Go(func() { s.waitAndExit(cmd, ptmx) }) + + return nil +} + +// StartPipe launches bash with plain stdin/stdout/stderr os.Pipes. +// Must be called with the WS lock held. +func (s *ptySession) StartPipe() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.pid != 0 { + return errors.New("pty session already started") + } + if s.closing { + return errors.New("pty session is closing") + } + + stdinR, stdinW, err := os.Pipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + _ = stdinR.Close() + _ = stdinW.Close() + return fmt.Errorf("stdout pipe: %w", err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + _ = stdinR.Close() + _ = stdinW.Close() + _ = stdoutR.Close() + _ = stdoutW.Close() + return fmt.Errorf("stderr pipe: %w", err) + } + + cmdArgs := []string{"--norc", "--noprofile"} + if s.command != "" { + cmdArgs = append(cmdArgs, "-c", s.command) + } + cmd := exec.Command("bash", cmdArgs...) + cmd.Env = os.Environ() + if s.cwd != "" { + cmd.Dir = s.cwd + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Stdin = stdinR + cmd.Stdout = stdoutW + cmd.Stderr = stderrW + + if err := cmd.Start(); err != nil { + _ = stdinR.Close() + _ = stdinW.Close() + _ = stdoutR.Close() + _ = stdoutW.Close() + _ = stderrR.Close() + _ = stderrW.Close() + return fmt.Errorf("cmd.Start: %w", err) + } + + // Close the child-side ends in the parent — the child has its own copies. + _ = stdinR.Close() + _ = stdoutW.Close() + _ = stderrW.Close() + + s.isPTY = false + s.pid = cmd.Process.Pid + s.doneCh = make(chan struct{}) + s.stdin = stdinW + + safego.Go(func() { s.broadcastPipe(stdoutR, true) }) + safego.Go(func() { s.broadcastPipe(stderrR, false) }) + safego.Go(func() { s.waitAndExitPipe(cmd, stdinW, stdoutR, stderrR) }) + + return nil +} + +// broadcastPTY reads from the PTY master and fans out to replay + active WS client. +func (s *ptySession) broadcastPTY() { + buf := make([]byte, 32*1024) + for { + n, err := s.ptmx.Read(buf) + if n > 0 { + s.writeAndFanout(buf[:n], true) + } + if err != nil { + // EIO or EOF when the child exits — normal termination + break + } + } +} + +// broadcastPipe reads from a pipe (stdout or stderr) and fans out to replay + active WS client. +func (s *ptySession) broadcastPipe(r *os.File, isStdout bool) { + buf := make([]byte, 32*1024) + for { + n, err := r.Read(buf) + if n > 0 { + s.writeAndFanout(buf[:n], isStdout) + } + if err != nil { + break + } + } + _ = r.Close() +} + +// writeAndFanout writes chunk to the replay buffer and delivers it to the +// active per-connection pipe, atomically under outMu. +// +// Holding outMu across both operations closes the window where bytes written +// to replay after ReadFrom but before AttachOutput would be silently dropped. +// Lock order is always outMu → replay.mu (both paths), so no deadlock is possible. +func (s *ptySession) writeAndFanout(chunk []byte, isStdout bool) { + s.outMu.Lock() + s.replay.write(chunk) // acquires replay.mu inside (outMu → replay.mu) + var w *io.PipeWriter + if isStdout { + w = s.stdoutW + } else { + w = s.stderrW + } + s.outMu.Unlock() + + if w != nil { + if _, err := w.Write(chunk); err != nil { + // Pipe was closed (client detached) — ignore. + log.Warn("pty fanout write: %v", err) + } + } +} + +// waitAndExit waits for the PTY-mode process and updates session state on exit. +func (s *ptySession) waitAndExit(cmd *exec.Cmd, ptmx *os.File) { + _ = cmd.Wait() + + // Close the PTY master to unblock the broadcast goroutine. + _ = ptmx.Close() + + s.mu.Lock() + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + s.lastExitCode = exitCode + s.pid = 0 + doneCh := s.doneCh + s.mu.Unlock() + + close(doneCh) +} + +// waitAndExitPipe waits for the pipe-mode process and updates session state on exit. +func (s *ptySession) waitAndExitPipe(cmd *exec.Cmd, stdinW, stdoutR, stderrR *os.File) { + _ = cmd.Wait() + + // Close stdin write-end so the child (if still running) sees EOF. + _ = stdinW.Close() + + s.mu.Lock() + exitCode := 0 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + s.lastExitCode = exitCode + s.pid = 0 + doneCh := s.doneCh + s.mu.Unlock() + + close(doneCh) +} + +// WriteStdin writes p to bash stdin (PTY master or pipe write-end). +func (s *ptySession) WriteStdin(p []byte) (int, error) { + s.mu.Lock() + w := s.stdin + s.mu.Unlock() + if w == nil { + return 0, errors.New("session not started") + } + return w.Write(p) +} + +// AttachOutput creates a fresh per-connection io.Pipe and swaps it into the +// broadcast fanout path. +// +// Ordering guarantee (no duplicates on reconnect): +// - Caller must snapshot the replay buffer BEFORE calling AttachOutput. +// - Bytes produced between the snapshot and AttachOutput are delivered via +// the live pipe only (not in the snapshot), so each byte arrives exactly once. +// +// Returns (stdout reader, stderr reader [nil in PTY mode], detach func). +// Calling detach() closes the writers, sending EOF to the readers and +// unblocking all pump goroutines. +func (s *ptySession) AttachOutput() (io.Reader, io.Reader, func()) { + stdoutR, stdoutW := io.Pipe() + + s.outMu.Lock() + s.stdoutW = stdoutW + s.outMu.Unlock() + + if s.isPTY { + detach := func() { + s.outMu.Lock() + s.stdoutW = nil + s.outMu.Unlock() + _ = stdoutW.Close() + } + return stdoutR, nil, detach + } + + // Pipe mode: also attach stderr. + stderrR, stderrW := io.Pipe() + + s.outMu.Lock() + s.stderrW = stderrW + s.outMu.Unlock() + + detach := func() { + s.outMu.Lock() + s.stdoutW = nil + s.stderrW = nil + s.outMu.Unlock() + _ = stdoutW.Close() + _ = stderrW.Close() + } + return stdoutR, stderrR, detach +} + +// AttachOutputWithSnapshot atomically snapshots the replay buffer and attaches +// the per-connection output pipe, eliminating the output-loss window that exists +// when ReadFrom and AttachOutput are called separately. +// +// Must be used together with writeAndFanout (which holds outMu during both +// replay.write and the fanout pointer read). +// +// Lock order is always outMu → replay.mu (both paths), so no deadlock is possible. +// +// Returns (stdoutR, stderrR [nil in PTY mode], detach, snapshotBytes, snapshotOffset). +func (s *ptySession) AttachOutputWithSnapshot(since int64) (io.Reader, io.Reader, func(), []byte, int64) { + stdoutR, stdoutW := io.Pipe() + var stderrR io.Reader + var stderrW *io.PipeWriter + if !s.isPTY { + stderrR, stderrW = io.Pipe() + } + + s.outMu.Lock() + snapshotBytes, snapshotOffset := s.replay.ReadFrom(since) // acquires replay.mu inside + s.stdoutW = stdoutW + if stderrW != nil { + s.stderrW = stderrW + } + s.outMu.Unlock() + + detach := func() { + s.outMu.Lock() + s.stdoutW = nil + if stderrW != nil { + s.stderrW = nil + } + s.outMu.Unlock() + _ = stdoutW.Close() + if stderrW != nil { + _ = stderrW.Close() + } + } + return stdoutR, stderrR, detach, snapshotBytes, snapshotOffset +} + +// SendSignal sends the named signal to the process group. +// Recognised names: SIGINT, SIGTERM, SIGKILL, SIGQUIT, SIGHUP. +func (s *ptySession) SendSignal(name string) { + s.mu.Lock() + pid := s.pid + s.mu.Unlock() + if pid == 0 { + return + } + + sig := parseSignalName(name) + if sig == 0 { + log.Warn("ptySession.SendSignal: unknown signal %q", name) + return + } + + // In PTY mode (setsid), pgid == pid automatically. + // In pipe mode (Setpgid), pgid is also == pid. + // Either way, Kill(-pid, sig) sends to the process group. + if err := syscall.Kill(-pid, sig); err != nil { + log.Warn("ptySession.SendSignal kill(-%d, %v): %v", pid, sig, err) + } +} + +func parseSignalName(name string) syscall.Signal { + switch name { + case "SIGINT": + return syscall.SIGINT + case "SIGTERM": + return syscall.SIGTERM + case "SIGKILL": + return syscall.SIGKILL + case "SIGQUIT": + return syscall.SIGQUIT + case "SIGHUP": + return syscall.SIGHUP + default: + return 0 + } +} + +// ResizePTY updates the terminal window size (PTY mode only; no-op in pipe mode). +func (s *ptySession) ResizePTY(cols, rows uint16) error { + s.mu.Lock() + ptmx := s.ptmx + s.mu.Unlock() + if ptmx == nil { + return nil // pipe mode or not started + } + return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} + +// close terminates the session and releases all resources. +// Safe to call multiple times. +func (s *ptySession) close() { + s.mu.Lock() + if s.closing { + s.mu.Unlock() + return + } + s.closing = true + pid := s.pid + ptmx := s.ptmx + stdin := s.stdin + s.mu.Unlock() + + if pid != 0 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + if ptmx != nil { + _ = ptmx.Close() + } else if stdin != nil { + _ = stdin.Close() + } + + // Detach any active WS output pipe so pump goroutines unblock. + s.outMu.Lock() + stdoutW := s.stdoutW + stderrW := s.stderrW + s.stdoutW = nil + s.stderrW = nil + s.outMu.Unlock() + if stdoutW != nil { + _ = stdoutW.Close() + } + if stderrW != nil { + _ = stderrW.Close() + } +} + +// CreatePTYSession creates a new PTY session and stores it in the map. +func (c *Controller) CreatePTYSession(id, cwd, command string) (PTYSession, error) { + resolvedCwd, err := pathutil.ExpandPath(cwd) + if err != nil { + return nil, fmt.Errorf("error resolving PTY session work directory: %w", err) + } + if resolvedCwd != "" { + err := os.MkdirAll(resolvedCwd, os.ModePerm) + if err != nil { + return nil, fmt.Errorf("error creating PTY session work directory: %w", err) + } + } + s := newPTYSession(id, resolvedCwd, command) + c.ptySessionMap.Store(id, s) + log.Info("created pty session %s", id) + return s, nil +} + +// getPTYSession looks up a PTY session by ID. Returns nil if not found. +// For internal use only; outside callers should use GetPTYSession. +func (c *Controller) getPTYSession(id string) *ptySession { + if v, ok := c.ptySessionMap.Load(id); ok { + if s, ok := v.(*ptySession); ok { + return s + } + } + return nil +} + +// GetPTYSession looks up a PTY session by ID. Returns nil if not found. +func (c *Controller) GetPTYSession(id string) PTYSession { + s := c.getPTYSession(id) + if s == nil { + return nil + } + return s +} + +// DeletePTYSession terminates and removes a PTY session. +// Returns ErrContextNotFound if the session does not exist. +func (c *Controller) DeletePTYSession(id string) error { + s := c.getPTYSession(id) + if s == nil { + return ErrContextNotFound + } + s.close() + c.ptySessionMap.Delete(id) + log.Info("deleted pty session %s", id) + return nil +} + +// GetPTYSessionStatus returns status information for a PTY session. +func (c *Controller) GetPTYSessionStatus(id string) (running bool, outputOffset int64, err error) { + s := c.getPTYSession(id) + if s == nil { + return false, 0, ErrContextNotFound + } + return s.IsRunning(), s.replay.Total(), nil +} diff --git a/components/execd/pkg/runtime/pty_session_test.go b/components/execd/pkg/runtime/pty_session_test.go new file mode 100644 index 0000000..656d9c3 --- /dev/null +++ b/components/execd/pkg/runtime/pty_session_test.go @@ -0,0 +1,369 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package runtime + +import ( + "bufio" + "io" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/internal/safego" +) + +// replayContains polls the replay buffer until it contains substr or timeout expires. +func replayContains(t *testing.T, s *ptySession, substr string, timeout time.Duration) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + data, _ := s.replay.ReadFrom(0) + if strings.Contains(string(data), substr) { + return true + } + time.Sleep(25 * time.Millisecond) + } + return false +} + +func TestPTYSession_BasicExecution(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + t.Cleanup(func() { s.close() }) + + stdoutR, _, detach := s.AttachOutput() + defer detach() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR) }) //nolint:errcheck + + _, err := s.WriteStdin([]byte("echo hello_pty\n")) + require.NoError(t, err) + + require.True(t, replayContains(t, s, "hello_pty", 5*time.Second), + "expected 'hello_pty' in PTY replay buffer") +} + +func TestPTYSession_IsRunning(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.False(t, s.IsRunning()) + require.NoError(t, s.StartPTY()) + t.Cleanup(func() { s.close() }) + require.True(t, s.IsRunning()) +} + +func TestPTYSession_ResizeWinsize(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + t.Cleanup(func() { s.close() }) + + // Attach output so the broadcast goroutine has a sink and fills the replay buffer. + stdoutR, _, detach := s.AttachOutput() + defer detach() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR) }) //nolint:errcheck + + // Wait for bash to start (prompt appears). + time.Sleep(150 * time.Millisecond) + + require.NoError(t, s.ResizePTY(120, 40)) + + // stty size reports "rows cols" (e.g. "40 120"). + _, err := s.WriteStdin([]byte("stty size\n")) + require.NoError(t, err) + + require.True(t, replayContains(t, s, "40 120", 5*time.Second), + "expected 'stty size' output '40 120' in replay buffer") +} + +func TestPTYSession_ANSISequences(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + t.Cleanup(func() { s.close() }) + + stdoutR, _, detach := s.AttachOutput() + defer detach() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR) }) //nolint:errcheck + + // Send printf with explicit ESC bytes via $'\033'. + _, err := s.WriteStdin([]byte("printf $'\\033[1;32mGREEN\\033[0m\\n'\n")) + require.NoError(t, err) + + // Wait for ESC from printf output. Do not match on "GREEN" alone: the echoed + // command line contains that substring before printf runs. + require.True(t, replayContains(t, s, "\x1b", 5*time.Second), + "expected ESC bytes in PTY replay buffer") + + data, _ := s.replay.ReadFrom(0) + assert.Contains(t, string(data), "GREEN", "expected GREEN text in PTY output") +} + +func TestPTYSession_PipeMode(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPipe()) + t.Cleanup(func() { s.close() }) + + require.False(t, s.IsPTY()) + + stdoutR, stderrR, detach := s.AttachOutput() + defer detach() + require.NotNil(t, stderrR) + + stdoutCh := make(chan string, 32) + safego.Go(func() { + sc := bufio.NewScanner(stdoutR) + for sc.Scan() { + stdoutCh <- sc.Text() + } + }) + + stderrCh := make(chan string, 32) + safego.Go(func() { + sc := bufio.NewScanner(stderrR) + for sc.Scan() { + stderrCh <- sc.Text() + } + }) + + _, err := s.WriteStdin([]byte("echo hello_pipe\necho err_pipe >&2\n")) + require.NoError(t, err) + + require.True(t, waitForLine(stdoutCh, "hello_pipe", 5*time.Second), + "expected 'hello_pipe' on stdout") + require.True(t, waitForLine(stderrCh, "err_pipe", 5*time.Second), + "expected 'err_pipe' on stderr") +} + +func TestPTYSession_ReconnectReplay(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + t.Cleanup(func() { s.close() }) + + // First connection — drain output so replay buffer fills. + stdoutR1, _, detach1 := s.AttachOutput() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR1) }) //nolint:errcheck + + _, err := s.WriteStdin([]byte("echo first_output\n")) + require.NoError(t, err) + + require.True(t, replayContains(t, s, "first_output", 5*time.Second), + "expected 'first_output' in replay buffer") + + snapshot, snapshotOff := s.replay.ReadFrom(0) + detach1() + time.Sleep(50 * time.Millisecond) + + // Reconnect — replay from offset 0 should return the same bytes we snapshotted. + replay, replayOff := s.replay.ReadFrom(0) + require.Equal(t, snapshotOff, replayOff) + // The new snapshot may be larger (more output arrived), but must contain snapshot. + require.True(t, strings.HasPrefix(string(replay), string(snapshot)) || len(replay) >= len(snapshot), + "replay should contain at least the original snapshot bytes") + + // Second connection. + stdoutR2, _, detach2 := s.AttachOutput() + defer detach2() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR2) }) //nolint:errcheck + + offsetAfterFirst := int64(len(replay)) + + _, err = s.WriteStdin([]byte("echo second_output\n")) + require.NoError(t, err) + + require.True(t, replayContains(t, s, "second_output", 5*time.Second), + "expected 'second_output' in replay buffer") + + // Delta replay from offset-after-first should contain only new bytes. + newData, _ := s.replay.ReadFrom(offsetAfterFirst) + require.True(t, strings.Contains(string(newData), "second_output"), + "delta replay should contain 'second_output', got %q", string(newData)) +} + +func TestPTYSession_SendSIGINT(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + t.Cleanup(func() { s.close() }) + + stdoutR, _, detach := s.AttachOutput() + defer detach() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR) }) //nolint:errcheck + + // Start a sleep inside the PTY. + _, err := s.WriteStdin([]byte("sleep 30\n")) + require.NoError(t, err) + time.Sleep(200 * time.Millisecond) + + // SIGINT interrupts the sleep; bash continues. + s.SendSignal("SIGINT") + + // Bash itself should still be alive. + require.Eventually(t, func() bool { + return s.IsRunning() + }, 2*time.Second, 50*time.Millisecond, "bash should still be running after SIGINT") +} + +func TestPTYSession_CloseTerminatesProcess(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + + require.True(t, s.IsRunning()) + s.close() + + done := s.Done() + if done != nil { + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("process did not exit within 3s after close()") + } + } +} + +func TestPTYSession_ExitCode(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "") + require.NoError(t, s.StartPTY()) + + stdoutR, _, detach := s.AttachOutput() + safego.Go(func() { _, _ = io.Copy(io.Discard, stdoutR) }) //nolint:errcheck + + _, _ = s.WriteStdin([]byte("exit 42\n")) + + done := s.Done() + select { + case <-done: + case <-time.After(5 * time.Second): + detach() + t.Fatal("bash did not exit within 5s") + } + detach() + + require.Equal(t, 42, s.ExitCode()) +} + +func TestPTYSession_LockWS(t *testing.T) { + s := newPTYSession(uuidString(), "", "") + require.True(t, s.LockWS(), "first lock should succeed") + require.False(t, s.LockWS(), "second lock should fail") + s.UnlockWS() + require.True(t, s.LockWS(), "lock after unlock should succeed") + s.UnlockWS() +} + +func TestPTYSession_CustomCommand(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + + s := newPTYSession(uuidString(), "", "echo hello_command") + require.NoError(t, s.StartPipe()) + t.Cleanup(func() { s.close() }) + + stdoutR, stderrR, detach := s.AttachOutput() + defer detach() + require.NotNil(t, stderrR) + + stdoutCh := make(chan string, 32) + safego.Go(func() { + sc := bufio.NewScanner(stdoutR) + for sc.Scan() { + stdoutCh <- sc.Text() + } + }) + + require.True(t, waitForLine(stdoutCh, "hello_command", 5*time.Second), + "expected 'hello_command' from custom command on stdout") + + // After the command exits, the session should no longer be running. + require.Eventually(t, func() bool { + return !s.IsRunning() + }, 3*time.Second, 50*time.Millisecond, "session should exit after custom command completes") +} + +func TestPTYSession_ControllerCRUD(t *testing.T) { + c := NewController("", "") + id := uuidString() + + sess, _ := c.CreatePTYSession(id, "", "") + require.NotNil(t, sess) + + got := c.GetPTYSession(id) + require.NotNil(t, got) + require.Equal(t, id, got.(*ptySession).id) + + running, offset, err := c.GetPTYSessionStatus(id) + require.NoError(t, err) + require.False(t, running) + require.Equal(t, int64(0), offset) + + require.NoError(t, c.DeletePTYSession(id)) + require.Nil(t, c.GetPTYSession(id)) + + require.ErrorIs(t, c.DeletePTYSession(id), ErrContextNotFound) +} + +// waitForLine reads from ch until target is found or timeout expires. +func waitForLine(ch <-chan string, target string, timeout time.Duration) bool { + deadline := time.After(timeout) + for { + select { + case line := <-ch: + if strings.Contains(line, target) { + return true + } + case <-deadline: + return false + } + } +} diff --git a/components/execd/pkg/runtime/pty_session_windows.go b/components/execd/pkg/runtime/pty_session_windows.go new file mode 100644 index 0000000..9202d9f --- /dev/null +++ b/components/execd/pkg/runtime/pty_session_windows.go @@ -0,0 +1,96 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package runtime + +import ( + "errors" + "io" + "time" +) + +// ptySession is an opaque stub on Windows (real type in pty_session.go, !windows). +// All methods the controller layer calls must be present here so Windows cross-compilation succeeds. +type ptySession struct{} + +// PTYSession is the public interface for an interactive PTY/pipe session. +// The concrete implementation (*ptySession) is unexported; callers outside +// this package must use this interface. +type PTYSession interface { + LockWS() bool + UnlockWS() + TakeoverWS(timeout time.Duration) bool + SetEvictHandler(fn func()) uint64 + ClearEvictHandler(gen uint64) + IsRunning() bool + IsPTY() bool + ExitCode() int + Done() <-chan struct{} + StartPTY() error + StartPipe() error + WriteStdin(p []byte) (int, error) + AttachOutput() (io.Reader, io.Reader, func()) + AttachOutputWithSnapshot(since int64) (io.Reader, io.Reader, func(), []byte, int64) + SendSignal(name string) + ResizePTY(cols, rows uint16) error +} + +var errPTYSessionNotSupported = errors.New("pty session is not supported on windows") + +// IsPTYSessionSupported reports whether PTY sessions are supported on this platform. +func IsPTYSessionSupported() bool { return false } + +// NewPTYSessionID returns a new unique session ID (Windows stub). +func NewPTYSessionID() string { return "" } + +// CreatePTYSession is not supported on Windows. +func (c *Controller) CreatePTYSession(id, cwd, command string) (PTYSession, error) { return nil, nil } //nolint:revive + +// GetPTYSession is not supported on Windows. +func (c *Controller) GetPTYSession(id string) PTYSession { return nil } //nolint:revive + +// DeletePTYSession is not supported on Windows. +func (c *Controller) DeletePTYSession(id string) error { //nolint:revive + return errPTYSessionNotSupported +} + +// GetPTYSessionStatus is not supported on Windows. +func (c *Controller) GetPTYSessionStatus(id string) (bool, int64, error) { //nolint:revive + return false, 0, errPTYSessionNotSupported +} + +// Method stubs so the controller layer can call them without build-tag guards. + +func (s *ptySession) LockWS() bool { return false } +func (s *ptySession) UnlockWS() {} +func (s *ptySession) TakeoverWS(_ time.Duration) bool { return false } +func (s *ptySession) SetEvictHandler(_ func()) uint64 { return 0 } +func (s *ptySession) ClearEvictHandler(_ uint64) {} +func (s *ptySession) IsRunning() bool { return false } +func (s *ptySession) IsPTY() bool { return false } +func (s *ptySession) ExitCode() int { return -1 } +func (s *ptySession) Done() <-chan struct{} { return nil } +func (s *ptySession) ReplayBuffer() *replayBuffer { return nil } +func (s *ptySession) StartPTY() error { return errPTYSessionNotSupported } +func (s *ptySession) StartPipe() error { return errPTYSessionNotSupported } +func (s *ptySession) WriteStdin(_ []byte) (int, error) { return 0, errPTYSessionNotSupported } +func (s *ptySession) AttachOutput() (io.Reader, io.Reader, func()) { return nil, nil, func() {} } +func (s *ptySession) AttachOutputWithSnapshot(_ int64) (io.Reader, io.Reader, func(), []byte, int64) { + return nil, nil, func() {}, nil, 0 +} +func (s *ptySession) SendSignal(_ string) {} +func (s *ptySession) ResizePTY(_, _ uint16) error { return nil } diff --git a/components/execd/pkg/runtime/replay_buffer.go b/components/execd/pkg/runtime/replay_buffer.go new file mode 100644 index 0000000..5d79e37 --- /dev/null +++ b/components/execd/pkg/runtime/replay_buffer.go @@ -0,0 +1,120 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import "sync" + +const replayBufferSize = 1 << 20 // 1 MiB + +// replayBuffer is a fixed-capacity circular byte buffer with a monotonic write counter. +// +// Invariant: head == total % size (where size is the buffer capacity). +// This means the byte at absolute offset o (o >= oldest) is stored at buf[o % size]. +type replayBuffer struct { + mu sync.Mutex + buf []byte + size int // == replayBufferSize (or smaller in tests) + head int // next write position; always == total % size + total int64 // monotonic byte counter (total bytes ever written) +} + +func newReplayBuffer() *replayBuffer { + return &replayBuffer{ + buf: make([]byte, replayBufferSize), + size: replayBufferSize, + } +} + +// write appends p to the buffer, evicting the oldest bytes when the buffer is full. +// The invariant head == total % size is preserved on every call. +func (r *replayBuffer) write(p []byte) { + if len(p) == 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + // When p is larger than the whole buffer we only keep the last size bytes, + // but we still advance total by the full len(p) to maintain the invariant. + if len(p) >= r.size { + skip := len(p) - r.size + r.total += int64(skip) + r.head = (r.head + skip) % r.size + p = p[skip:] + // len(p) == r.size now + } + + // len(p) <= r.size — split into at most two contiguous copies. + n := copy(r.buf[r.head:], p) + r.head = (r.head + n) % r.size + r.total += int64(n) + + if n < len(p) { + rest := p[n:] + copy(r.buf[r.head:], rest) + r.head = (r.head + len(rest)) % r.size + r.total += int64(len(rest)) + } +} + +// Total returns the total number of bytes ever written to the buffer. +func (r *replayBuffer) Total() int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.total +} + +// ReadFrom returns a snapshot of all bytes starting from the given absolute byte offset. +// +// Returns (data, actualOffset) where actualOffset is the offset of the first returned byte: +// - If offset >= total, returns (nil, total) — caller is already caught up. +// - If offset < oldest retained byte, clamps to oldest (bytes were evicted). +// - Otherwise returns bytes [offset, total). +func (r *replayBuffer) ReadFrom(offset int64) ([]byte, int64) { + r.mu.Lock() + defer r.mu.Unlock() + + if offset >= r.total { + return nil, r.total + } + + // Oldest retained absolute offset. + oldest := r.total - int64(r.size) + if oldest < 0 { + oldest = 0 + } + if offset < oldest { + offset = oldest + } + + count := int(r.total - offset) + if count <= 0 { + return nil, r.total + } + + // Thanks to the invariant head == total % size, the byte at absolute offset o + // is stored at buf[o % size]. This holds whether or not the buffer has wrapped. + start := int(offset % int64(r.size)) + end := start + count + + result := make([]byte, count) + if end <= r.size { + copy(result, r.buf[start:end]) + } else { + n := copy(result, r.buf[start:]) + copy(result[n:], r.buf[:count-n]) + } + return result, offset +} diff --git a/components/execd/pkg/runtime/replay_buffer_test.go b/components/execd/pkg/runtime/replay_buffer_test.go new file mode 100644 index 0000000..e9438e8 --- /dev/null +++ b/components/execd/pkg/runtime/replay_buffer_test.go @@ -0,0 +1,154 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "bytes" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReplayBuffer_BasicWriteRead(t *testing.T) { + rb := newReplayBuffer() + rb.write([]byte("hello")) + rb.write([]byte(" world")) + + data, off := rb.ReadFrom(0) + require.Equal(t, int64(0), off) + require.Equal(t, []byte("hello world"), data) + require.Equal(t, int64(11), rb.Total()) +} + +func TestReplayBuffer_ReadFromMiddle(t *testing.T) { + rb := newReplayBuffer() + rb.write([]byte("abcde")) + + data, off := rb.ReadFrom(2) + require.Equal(t, int64(2), off) + require.Equal(t, []byte("cde"), data) +} + +func TestReplayBuffer_ReadFromCurrent(t *testing.T) { + rb := newReplayBuffer() + rb.write([]byte("abc")) + + data, off := rb.ReadFrom(3) + require.Nil(t, data, "should return nil when caught up") + require.Equal(t, int64(3), off) +} + +func TestReplayBuffer_CircularEviction(t *testing.T) { + rb := &replayBuffer{ + buf: make([]byte, 8), + size: 8, + } + + // Write 6 bytes: "abcdef" + rb.write([]byte("abcdef")) + require.Equal(t, int64(6), rb.Total()) + + // Write 4 more bytes: now total=10, oldest=2 (evicted "ab") + rb.write([]byte("ghij")) + require.Equal(t, int64(10), rb.Total()) + + // offset 0 should be clamped to oldest=2 + data, off := rb.ReadFrom(0) + require.Equal(t, int64(2), off) + require.Equal(t, []byte("cdefghij"), data) + + // Read from offset 5 (within retained range) + data, off = rb.ReadFrom(5) + require.Equal(t, int64(5), off) + require.Equal(t, []byte("fghij"), data) +} + +func TestReplayBuffer_LargeGap(t *testing.T) { + rb := &replayBuffer{ + buf: make([]byte, 4), + size: 4, + } + // Write "ABCDEF" — total=6, oldest=2, retained="CDEF" + rb.write([]byte("ABCDEF")) + + // Requesting from 0 should clamp to oldest=2 + data, off := rb.ReadFrom(0) + require.Equal(t, int64(2), off) + require.Equal(t, []byte("CDEF"), data) + + // Requesting from 1 should also clamp to oldest=2 + data, off = rb.ReadFrom(1) + require.Equal(t, int64(2), off) + require.Equal(t, []byte("CDEF"), data) +} + +func TestReplayBuffer_Concurrent(t *testing.T) { + rb := newReplayBuffer() + chunk := bytes.Repeat([]byte("x"), 1024) + + var wg sync.WaitGroup + for range 16 { + wg.Add(1) + go func() { + defer wg.Done() + for range 64 { + rb.write(chunk) + } + }() + } + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for range 32 { + rb.ReadFrom(0) + rb.Total() + } + }() + } + wg.Wait() + + total := rb.Total() + require.Equal(t, int64(16*64*1024), total) +} + +func TestReplayBuffer_ExactlyFull(t *testing.T) { + rb := &replayBuffer{ + buf: make([]byte, 4), + size: 4, + } + rb.write([]byte("1234")) + require.Equal(t, int64(4), rb.Total()) + + data, off := rb.ReadFrom(0) + require.Equal(t, int64(0), off) + require.Equal(t, []byte("1234"), data) +} + +func TestReplayBuffer_WriteWrapsCorrectly(t *testing.T) { + rb := &replayBuffer{ + buf: make([]byte, 4), + size: 4, + } + // Write "ABCD" — buffer full + rb.write([]byte("ABCD")) + // Write "EF" — evicts "AB", retained "CDEF" + rb.write([]byte("EF")) + + data, off := rb.ReadFrom(0) + require.Equal(t, int64(2), off, "offset should be clamped to oldest=2") + require.Equal(t, []byte("CDEF"), data) +} diff --git a/components/execd/pkg/runtime/sql.go b/components/execd/pkg/runtime/sql.go new file mode 100644 index 0000000..c8174e0 --- /dev/null +++ b/components/execd/pkg/runtime/sql.go @@ -0,0 +1,212 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + _ "github.com/go-sql-driver/mysql" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +// QueryResult represents a SQL query response. +type QueryResult struct { + Columns []string `json:"columns,omitempty"` + Rows [][]any `json:"rows,omitempty"` + Error string `json:"error,omitempty"` +} + +// runSQL executes SQL queries based on their type. +func (c *Controller) runSQL(ctx context.Context, request *ExecuteCodeRequest) error { + request.Hooks.OnExecuteInit(uuid.New().String()) + err := c.initDB() + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBInitError", EValue: err.Error()}) + log.Error("DBInitError: error initializing db server: %v", err) + return err + } + + err = c.db.PingContext(ctx) + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBPingError", EValue: err.Error()}) + log.Error("DBPingError: error pinging db server: %v", err) + return err + } + + switch c.getQueryType(request.Code) { + case "SELECT": + return c.executeSelectSQLQuery(ctx, request) + default: + return c.executeUpdateSQLQuery(ctx, request) + } +} + +// executeSelectSQLQuery handles SELECT statements. +func (c *Controller) executeSelectSQLQuery(ctx context.Context, request *ExecuteCodeRequest) error { + startAt := time.Now() + + // The SQL runtime intentionally executes the user's submitted SQL program + // against the sandbox-local database; no trusted query template is composed here. + rows, err := c.db.QueryContext(ctx, request.Code) // lgtm[go/sql-injection] + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBQueryError", EValue: err.Error()}) + return nil + } + defer rows.Close() + + columns, err := rows.Columns() + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBQueryError", EValue: err.Error()}) + return nil + } + + var result [][]any + values := make([]any, len(columns)) + scanArgs := make([]any, len(columns)) + for i := range values { + scanArgs[i] = &values[i] + } + + for rows.Next() { + err := rows.Scan(scanArgs...) + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "RowScanError", EValue: err.Error()}) + return nil + } + row := make([]any, len(columns)) + for i, v := range values { + if v == nil { + row[i] = nil + } else { + row[i] = fmt.Sprintf("%v", v) + } + } + result = append(result, row) + } + if err := rows.Err(); err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "RowIterationError", EValue: err.Error()}) + return nil + } + + queryResult := QueryResult{ + Columns: columns, + Rows: result, + } + bytes, err := json.Marshal(queryResult) + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "JSONMarshalError", EValue: err.Error()}) + return nil + } + request.Hooks.OnExecuteResult( + map[string]any{ + "text/plain": string(bytes), + }, + 1, + ) + request.Hooks.OnExecuteComplete(time.Since(startAt)) + return nil +} + +// executeUpdateSQLQuery handles non-SELECT statements. +func (c *Controller) executeUpdateSQLQuery(ctx context.Context, request *ExecuteCodeRequest) error { + startAt := time.Now() + + // The SQL runtime intentionally executes the user's submitted SQL program + // against the sandbox-local database; no trusted query template is composed here. + result, err := c.db.ExecContext(ctx, request.Code) // lgtm[go/sql-injection] + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBExecError", EValue: err.Error()}) + return err + } + + affected, _ := result.RowsAffected() + queryResult := QueryResult{ + Rows: [][]any{{affected}}, + Columns: []string{"affected_rows"}, + } + bytes, err := json.Marshal(queryResult) + if err != nil { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "JSONMarshalError", EValue: err.Error()}) + return err + } + request.Hooks.OnExecuteResult( + map[string]any{ + "text/plain": string(bytes), + }, + 1, + ) + request.Hooks.OnExecuteComplete(time.Since(startAt)) + return nil +} + +// getQueryType extracts the first token to decide which executor to use. +func (c *Controller) getQueryType(query string) string { + fields := strings.Fields(query) + if len(fields) == 0 { + return "" + } + return strings.ToUpper(fields[0]) +} + +// initDB lazily opens the local sandbox database. +func (c *Controller) initDB() error { + var initErr error + c.dbOnce.Do(func() { + dsn := "root:@tcp(127.0.0.1:3306)/" + db, err := sql.Open("mysql", dsn) + if err != nil { + initErr = err + return + } + + err = db.Ping() + if err != nil { + initErr = err + return + } + + _, err = db.Exec("CREATE DATABASE IF NOT EXISTS sandbox") + if err != nil { + initErr = err + return + } + + _, err = db.Exec("USE sandbox") + if err != nil { + initErr = err + return + } + + c.db = db + }) + + if initErr != nil { + return initErr + } + if c.db == nil { + return errors.New("db is not initialized") + } + return nil +} diff --git a/components/execd/pkg/runtime/sql_test.go b/components/execd/pkg/runtime/sql_test.go new file mode 100644 index 0000000..bb103fb --- /dev/null +++ b/components/execd/pkg/runtime/sql_test.go @@ -0,0 +1,122 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "context" + "database/sql/driver" + "encoding/json" + "testing" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/stretchr/testify/require" +) + +func TestExecuteSelectSQLQuery_Success(t *testing.T) { + driver := &stubDriver{ + columns: []string{"id", "name"}, + rows: [][]driver.Value{ + {int64(1), "alice"}, + {int64(2), "bob"}, + }, + } + db := newStubDB(t, driver) + + c := NewController("", "") + c.db = db + + var ( + gotResult map[string]any + gotError *execute.ErrorOutput + completed bool + ) + + req := &ExecuteCodeRequest{ + Code: "SELECT * FROM users", + Hooks: ExecuteResultHook{ + OnExecuteResult: func(result map[string]any, _ int) { + gotResult = result + }, + OnExecuteError: func(err *execute.ErrorOutput) { + gotError = err + }, + OnExecuteComplete: func(time.Duration) { + completed = true + }, + }, + } + + require.NoError(t, c.executeSelectSQLQuery(context.Background(), req)) + + require.Nil(t, gotError, "unexpected error hook") + require.True(t, completed, "expected completion hook to be triggered") + + raw, ok := gotResult["text/plain"] + require.True(t, ok, "expected text/plain payload") + var qr QueryResult + require.NoError(t, json.Unmarshal([]byte(raw.(string)), &qr)) + + require.Equal(t, []string{"id", "name"}, qr.Columns, "unexpected columns") + require.Len(t, qr.Rows, 2, "unexpected rows") + require.Equal(t, "1", qr.Rows[0][0]) + require.Equal(t, "bob", qr.Rows[1][1]) +} + +func TestExecuteUpdateSQLQuery_Success(t *testing.T) { + driver := &stubDriver{ + execRowsAffected: 3, + } + db := newStubDB(t, driver) + + c := NewController("", "") + c.db = db + + var ( + gotResult map[string]any + gotError *execute.ErrorOutput + completed bool + ) + + req := &ExecuteCodeRequest{ + Code: "UPDATE users SET name='alice' WHERE id=1", + Hooks: ExecuteResultHook{ + OnExecuteResult: func(result map[string]any, _ int) { + gotResult = result + }, + OnExecuteError: func(err *execute.ErrorOutput) { + gotError = err + }, + OnExecuteComplete: func(time.Duration) { + completed = true + }, + }, + } + + require.NoError(t, c.executeUpdateSQLQuery(context.Background(), req)) + + require.Nil(t, gotError, "unexpected error hook") + require.True(t, completed, "expected completion hook to be triggered") + + raw, ok := gotResult["text/plain"] + require.True(t, ok, "expected text/plain payload") + var qr QueryResult + require.NoError(t, json.Unmarshal([]byte(raw.(string)), &qr)) + + require.Equal(t, []string{"affected_rows"}, qr.Columns, "unexpected columns") + require.Len(t, qr.Rows, 1, "unexpected rows length") + require.Len(t, qr.Rows[0], 1, "unexpected row entry length") + require.Equal(t, float64(3), qr.Rows[0][0]) +} diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go new file mode 100644 index 0000000..cd0615c --- /dev/null +++ b/components/execd/pkg/runtime/types.go @@ -0,0 +1,110 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "fmt" + "sync" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" +) + +// ExecuteResultHook groups execution callbacks. +type ExecuteResultHook struct { + OnExecuteInit func(context string) + OnExecuteResult func(result map[string]any, count int) + OnExecuteStatus func(status string) + OnExecuteStdout func(stdout string) //nolint:predeclared + OnExecuteStderr func(stderr string) //nolint:predeclared + OnExecuteError func(err *execute.ErrorOutput) + OnExecuteComplete func(executionTime time.Duration) +} + +// ExecuteCodeRequest represents a code execution request with context and hooks. +type ExecuteCodeRequest struct { + Language Language `json:"language"` + Code string `json:"code"` + Context string `json:"context"` + Timeout time.Duration `json:"timeout"` + Cwd string `json:"cwd"` + Envs map[string]string `json:"envs"` + Uid *uint32 `json:"uid,omitempty"` + Gid *uint32 `json:"gid,omitempty"` + Hooks ExecuteResultHook +} + +// SetDefaultHooks installs stdout logging fallbacks for unset hooks. +func (req *ExecuteCodeRequest) SetDefaultHooks() { + if req.Hooks.OnExecuteResult == nil { + req.Hooks.OnExecuteResult = func(result map[string]any, count int) { fmt.Printf("OnExecuteResult: %d, %++v\n", count, result) } + } + if req.Hooks.OnExecuteStatus == nil { + req.Hooks.OnExecuteStatus = func(status string) { fmt.Printf("OnExecuteStatus: %s\n", status) } + } + if req.Hooks.OnExecuteStdout == nil { + req.Hooks.OnExecuteStdout = func(stdout string) { fmt.Printf("OnExecuteStdout: %s\n", stdout) } + } + if req.Hooks.OnExecuteStderr == nil { + req.Hooks.OnExecuteStderr = func(stderr string) { fmt.Printf("OnExecuteStderr: %s\n", stderr) } + } + if req.Hooks.OnExecuteError == nil { + req.Hooks.OnExecuteError = func(err *execute.ErrorOutput) { fmt.Printf("OnExecuteError: %++v\n", err) } + } + if req.Hooks.OnExecuteComplete == nil { + req.Hooks.OnExecuteComplete = func(executionTime time.Duration) { + fmt.Printf("OnExecuteComplete: %v\n", executionTime) + } + } + if req.Hooks.OnExecuteInit == nil { + req.Hooks.OnExecuteInit = func(session string) { fmt.Printf("OnExecuteInit: %s\n", session) } + } +} + +// CreateContextRequest represents a stateful session creation request. +type CreateContextRequest struct { + Language Language `json:"language"` + Cwd string `json:"cwd"` +} + +type CodeContext struct { + ID string `json:"id,omitempty"` + Language Language `json:"language"` +} + +// bashSessionConfig holds bash session configuration. +type bashSessionConfig struct { + // StartupSource is a list of scripts sourced on startup. + StartupSource []string + // Session is the session identifier. + Session string + // StartupTimeout is the startup timeout. + StartupTimeout time.Duration + // Cwd is the working directory. + Cwd string +} + +// bashSession represents a bash session. +type bashSession struct { + config *bashSessionConfig + mu sync.Mutex + started bool + env map[string]string + cwd string + + // currentProcessPid is the pid of the active run's process group leader (bash). + // Set after cmd.Start(), cleared when run() returns. Used by close() to kill the process group. + currentProcessPid int +} diff --git a/components/execd/pkg/runtime/types_test.go b/components/execd/pkg/runtime/types_test.go new file mode 100644 index 0000000..5b7afeb --- /dev/null +++ b/components/execd/pkg/runtime/types_test.go @@ -0,0 +1,41 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExecuteCodeRequest_SetDefaultHooks(t *testing.T) { + customResult := func(map[string]any, int) {} + + req := &ExecuteCodeRequest{ + Hooks: ExecuteResultHook{ + OnExecuteResult: customResult, + }, + } + + req.SetDefaultHooks() + + require.NotNil(t, req.Hooks.OnExecuteStdout) + require.NotNil(t, req.Hooks.OnExecuteStderr) + require.NotNil(t, req.Hooks.OnExecuteError) + require.NotNil(t, req.Hooks.OnExecuteResult, "expected OnExecuteResult to remain set") + require.Equal(t, reflect.ValueOf(customResult).Pointer(), reflect.ValueOf(req.Hooks.OnExecuteResult).Pointer(), + "default hooks should not override existing ones") +} diff --git a/components/execd/pkg/runtime/workingdir.go b/components/execd/pkg/runtime/workingdir.go new file mode 100644 index 0000000..2e43cf1 --- /dev/null +++ b/components/execd/pkg/runtime/workingdir.go @@ -0,0 +1,45 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "errors" + "fmt" + "io/fs" + "os" + + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" +) + +func ValidateWorkingDir(cwd string) error { + if cwd == "" { + return nil + } + resolvedCwd, err := pathutil.ExpandPath(cwd) + if err != nil { + return fmt.Errorf("cannot resolve working directory %q: %w", cwd, err) + } + fi, err := os.Stat(resolvedCwd) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("working directory does not exist: %s: %w", cwd, err) + } + return fmt.Errorf("cannot access working directory %q: %w", cwd, err) + } + if !fi.IsDir() { + return fmt.Errorf("working directory path is not a directory: %s", cwd) + } + return nil +} diff --git a/components/execd/pkg/runtime/workingdir_test.go b/components/execd/pkg/runtime/workingdir_test.go new file mode 100644 index 0000000..4b6f967 --- /dev/null +++ b/components/execd/pkg/runtime/workingdir_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runtime + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateWorkingDir_empty(t *testing.T) { + require.NoError(t, ValidateWorkingDir("")) +} + +func TestValidateWorkingDir_notExist(t *testing.T) { + tmp := t.TempDir() + missing := filepath.Join(tmp, "definitely-missing-subdir") + err := ValidateWorkingDir(missing) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + require.Contains(t, err.Error(), missing) +} + +func TestValidateWorkingDir_notDir(t *testing.T) { + tmp := t.TempDir() + f := filepath.Join(tmp, "file") + require.NoError(t, os.WriteFile(f, []byte("x"), 0o600)) + err := ValidateWorkingDir(f) + require.Error(t, err) + require.Contains(t, err.Error(), "not a directory") +} + +func TestValidateWorkingDir_ok(t *testing.T) { + require.NoError(t, ValidateWorkingDir(t.TempDir())) +} + +func TestValidateWorkingDir_ExpandsHome(t *testing.T) { + home := t.TempDir() + target := filepath.Join(home, "workspace") + require.NoError(t, os.MkdirAll(target, 0o755)) + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + require.NoError(t, ValidateWorkingDir("~/workspace")) +} diff --git a/components/execd/pkg/telemetry/init.go b/components/execd/pkg/telemetry/init.go new file mode 100644 index 0000000..60eeda2 --- /dev/null +++ b/components/execd/pkg/telemetry/init.go @@ -0,0 +1,201 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + "os" + "strings" + "sync" + + inttelemetry "github.com/alibaba/opensandbox/internal/telemetry" + "github.com/alibaba/opensandbox/internal/version" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const ( + serviceName = "opensandbox-execd" + envSandboxID = "OPENSANDBOX_ID" + envMetricsExtraAttr = "OPENSANDBOX_EXECD_METRICS_EXTRA_ATTRS" +) + +var ( + httpRequestDuration metric.Float64Histogram + executionDuration metric.Float64Histogram + filesystemOperationDurMs metric.Float64Histogram + isolationRunDurationMs metric.Float64Histogram +) + +func Init(ctx context.Context) (shutdown func(context.Context) error, err error) { + var resourceAttrs []attribute.KeyValue + if id := strings.TrimSpace(os.Getenv(envSandboxID)); id != "" { + resourceAttrs = append(resourceAttrs, attribute.String("sandbox_id", id)) + } + + return inttelemetry.Init(ctx, inttelemetry.Config{ + ServiceName: serviceName + "-" + version.Version, + ResourceAttributes: resourceAttrs, + RegisterMetrics: registerExecdMetrics, + }) +} + +func registerExecdMetrics() error { + meter := otel.Meter("opensandbox/execd") + + var err error + httpRequestDuration, err = meter.Float64Histogram( + "execd.http.request.duration", + metric.WithDescription("HTTP request duration by method and route template"), + metric.WithUnit("ms"), + ) + if err != nil { + return err + } + + executionDuration, err = meter.Float64Histogram( + "execd.execution.duration", + metric.WithDescription("Duration per execution"), + metric.WithUnit("ms"), + ) + if err != nil { + return err + } + + filesystemOperationDurMs, err = meter.Float64Histogram( + "execd.filesystem.operations.duration", + metric.WithDescription("Filesystem operation duration by type"), + metric.WithUnit("ms"), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "execd.system.process.count", + metric.WithDescription("Current number of processes in the system"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + obs.Observe(systemProcessCount(), metric.WithAttributes(execdSharedAttrs()...)) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Float64ObservableGauge( + "execd.system.cpu.usage", + metric.WithDescription("System-wide CPU usage percentage"), + metric.WithUnit("%"), + metric.WithFloat64Callback(func(ctx context.Context, obs metric.Float64Observer) error { + obs.Observe(systemCPUUsagePercent(), metric.WithAttributes(execdSharedAttrs()...)) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "execd.system.memory.usage_bytes", + metric.WithDescription("System memory used bytes"), + metric.WithUnit("By"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + obs.Observe(systemMemoryUsageBytes(), metric.WithAttributes(execdSharedAttrs()...)) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableCounter( + "execd.system.network.io.bytes", + metric.WithDescription("System network IO bytes by direction"), + metric.WithUnit("By"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + inBytes, outBytes := systemNetworkIOBytes() + base := append([]attribute.KeyValue{}, execdSharedAttrs()...) + obs.Observe(inBytes, metric.WithAttributes(append(base, attribute.String("direction", "in"))...)) + obs.Observe(outBytes, metric.WithAttributes(append(base, attribute.String("direction", "out"))...)) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "execd.system.network.connections.active", + metric.WithDescription("Current active network connections by protocol"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + tcpCount, udpCount := systemNetworkConnectionCounts() + base := append([]attribute.KeyValue{}, execdSharedAttrs()...) + obs.Observe(tcpCount, metric.WithAttributes(append(base, attribute.String("protocol", "tcp"))...)) + obs.Observe(udpCount, metric.WithAttributes(append(base, attribute.String("protocol", "udp"))...)) + return nil + }), + ) + if err != nil { + return err + } + + isolationRunDurationMs, err = meter.Float64Histogram( + "execd.isolation.run.duration", + metric.WithDescription("Duration of isolated session runs by result"), + metric.WithUnit("ms"), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "execd.isolation.session.count", + metric.WithDescription("Current number of active isolated sessions"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + if isolationStatsProvider == nil { + return nil + } + obs.Observe(isolationStatsProvider().ActiveSessions, metric.WithAttributes(execdSharedAttrs()...)) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "execd.isolation.upper.usage_bytes", + metric.WithDescription("Total bytes used by isolated session upper directories"), + metric.WithUnit("By"), + metric.WithInt64Callback(func(ctx context.Context, obs metric.Int64Observer) error { + if isolationStatsProvider == nil { + return nil + } + obs.Observe(isolationStatsProvider().UpperUsageBytes, metric.WithAttributes(execdSharedAttrs()...)) + return nil + }), + ) + return err +} + +var execdSharedAttrs = sync.OnceValue(func() []attribute.KeyValue { + return inttelemetry.SharedAttrsFromEnv(inttelemetry.SharedAttrsEnvConfig{ + SandboxIDEnv: envSandboxID, + ExtraAttrsEnv: envMetricsExtraAttr, + SandboxAttr: "sandbox_id", + }) +}) diff --git a/components/execd/pkg/telemetry/init_test.go b/components/execd/pkg/telemetry/init_test.go new file mode 100644 index 0000000..31d3070 --- /dev/null +++ b/components/execd/pkg/telemetry/init_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/attribute" + + inttelemetry "github.com/alibaba/opensandbox/internal/telemetry" +) + +func TestNormalizeRoute(t *testing.T) { + t.Parallel() + + assert.Equal(t, "unknown", normalizeRoute("")) + assert.Equal(t, "/code/contexts/:contextId", normalizeRoute("/code/contexts/:contextId")) +} + +func TestRecordHTTPRequestWithoutInit(t *testing.T) { + t.Parallel() + + RecordHTTPRequest(context.Background(), "GET", "/ping", 200, 0.01) +} + +func TestSystemMetricsReaders(t *testing.T) { + t.Parallel() + + assert.GreaterOrEqual(t, systemProcessCount(), int64(0)) + assert.GreaterOrEqual(t, systemCPUUsagePercent(), 0.0) + assert.GreaterOrEqual(t, systemMemoryUsageBytes(), int64(0)) + inBytes, outBytes := systemNetworkIOBytes() + assert.GreaterOrEqual(t, inBytes, int64(0)) + assert.GreaterOrEqual(t, outBytes, int64(0)) + tcpCount, udpCount := systemNetworkConnectionCounts() + assert.GreaterOrEqual(t, tcpCount, int64(0)) + assert.GreaterOrEqual(t, udpCount, int64(0)) +} + +func TestExecdSharedAttrs(t *testing.T) { + t.Setenv(envSandboxID, "sb-123") + t.Setenv(envMetricsExtraAttr, "tenant=t1,env=dev") + + orig := execdSharedAttrs + execdSharedAttrs = sync.OnceValue(func() []attribute.KeyValue { + return inttelemetry.SharedAttrsFromEnv(inttelemetry.SharedAttrsEnvConfig{ + SandboxIDEnv: envSandboxID, + ExtraAttrsEnv: envMetricsExtraAttr, + SandboxAttr: "sandbox_id", + }) + }) + t.Cleanup(func() { execdSharedAttrs = orig }) + + attrs := execdSharedAttrs() + assert.Len(t, attrs, 3) +} diff --git a/components/execd/pkg/telemetry/isolation.go b/components/execd/pkg/telemetry/isolation.go new file mode 100644 index 0000000..ffe8da1 --- /dev/null +++ b/components/execd/pkg/telemetry/isolation.go @@ -0,0 +1,50 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// IsolationStats holds current isolation subsystem state for gauge callbacks. +type IsolationStats struct { + ActiveSessions int64 + UpperUsageBytes int64 +} + +// IsolationStatsProvider is called by observable gauge callbacks to get +// the latest isolation stats. +type IsolationStatsProvider func() IsolationStats + +var isolationStatsProvider IsolationStatsProvider + +// SetIsolationStatsProvider registers a callback the telemetry gauges will +// query on each collection interval. +func SetIsolationStatsProvider(p IsolationStatsProvider) { + isolationStatsProvider = p +} + +// RecordIsolatedRun records the duration of an isolated session run. +func RecordIsolatedRun(ctx context.Context, result string, durationMillis float64) { + if isolationRunDurationMs == nil { + return + } + attrs := append([]attribute.KeyValue{}, execdSharedAttrs()...) + attrs = append(attrs, attribute.String("result", result)) + isolationRunDurationMs.Record(ctx, durationMillis, metric.WithAttributes(attrs...)) +} diff --git a/components/execd/pkg/telemetry/isolation_test.go b/components/execd/pkg/telemetry/isolation_test.go new file mode 100644 index 0000000..418be6b --- /dev/null +++ b/components/execd/pkg/telemetry/isolation_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRecordIsolatedRunWithoutInit(t *testing.T) { + t.Parallel() + + // Must not panic when instruments aren't initialised. + RecordIsolatedRun(context.Background(), "success", 42.0) + RecordIsolatedRun(context.Background(), "error", 5.0) +} + +func TestIsolationStatsProvider(t *testing.T) { + orig := isolationStatsProvider + t.Cleanup(func() { isolationStatsProvider = orig }) + + assert.Nil(t, isolationStatsProvider) + + called := false + SetIsolationStatsProvider(func() IsolationStats { + called = true + return IsolationStats{ActiveSessions: 3, UpperUsageBytes: 1024} + }) + + assert.NotNil(t, isolationStatsProvider) + stats := isolationStatsProvider() + assert.True(t, called) + assert.Equal(t, int64(3), stats.ActiveSessions) + assert.Equal(t, int64(1024), stats.UpperUsageBytes) +} diff --git a/components/execd/pkg/telemetry/record.go b/components/execd/pkg/telemetry/record.go new file mode 100644 index 0000000..6f04cca --- /dev/null +++ b/components/execd/pkg/telemetry/record.go @@ -0,0 +1,69 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +func RecordHTTPRequest(ctx context.Context, method, route string, statusCode int, durationMillis float64) { + if httpRequestDuration == nil { + return + } + + attrs := append([]attribute.KeyValue{}, execdSharedAttrs()...) + attrs = append(attrs, + attribute.String("http_method", method), + attribute.String("http_route", normalizeRoute(route)), + attribute.Int("http_status_code", statusCode), + ) + opt := metric.WithAttributes(attrs...) + + httpRequestDuration.Record(ctx, durationMillis, opt) +} + +func RecordExecutionDuration(ctx context.Context, operation, result string, durationMillis float64) { + if executionDuration == nil { + return + } + attrs := append([]attribute.KeyValue{}, execdSharedAttrs()...) + attrs = append(attrs, + attribute.String("operation", operation), + attribute.String("result", result), + ) + executionDuration.Record(ctx, durationMillis, metric.WithAttributes(attrs...)) +} + +func RecordFilesystemOperation(ctx context.Context, operation, result string, durationMillis float64) { + if filesystemOperationDurMs == nil { + return + } + attrs := append([]attribute.KeyValue{}, execdSharedAttrs()...) + attrs = append(attrs, + attribute.String("operation", operation), + attribute.String("result", result), + ) + filesystemOperationDurMs.Record(ctx, durationMillis, metric.WithAttributes(attrs...)) +} + +func normalizeRoute(route string) string { + if route == "" { + return "unknown" + } + return route +} diff --git a/components/execd/pkg/telemetry/system.go b/components/execd/pkg/telemetry/system.go new file mode 100644 index 0000000..cf3d954 --- /dev/null +++ b/components/execd/pkg/telemetry/system.go @@ -0,0 +1,66 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/mem" + gopsnet "github.com/shirou/gopsutil/net" + "github.com/shirou/gopsutil/process" +) + +func systemProcessCount() int64 { + pids, err := process.Pids() + if err != nil { + return 0 + } + return int64(len(pids)) +} + +func systemCPUUsagePercent() float64 { + usage, err := cpu.Percent(0, false) + if err != nil || len(usage) == 0 { + return 0 + } + return usage[0] +} + +func systemMemoryUsageBytes() int64 { + stats, err := mem.VirtualMemory() + if err != nil { + return 0 + } + return int64(stats.Used) +} + +func systemNetworkIOBytes() (inBytes int64, outBytes int64) { + counters, err := gopsnet.IOCounters(false) + if err != nil || len(counters) == 0 { + return 0, 0 + } + return int64(counters[0].BytesRecv), int64(counters[0].BytesSent) +} + +func systemNetworkConnectionCounts() (tcpCount int64, udpCount int64) { + tcp, err := gopsnet.Connections("tcp") + if err == nil { + tcpCount = int64(len(tcp)) + } + udp, err := gopsnet.Connections("udp") + if err == nil { + udpCount = int64(len(udp)) + } + return tcpCount, udpCount +} diff --git a/components/execd/pkg/util/glob/index.go b/components/execd/pkg/util/glob/index.go new file mode 100644 index 0000000..8afc64c --- /dev/null +++ b/components/execd/pkg/util/glob/index.go @@ -0,0 +1,72 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package glob + +func findUnescapedByteIndex(s string, c byte, allowEscaping bool) int { + l := len(s) + for i := 0; i < l; i++ { + if allowEscaping && s[i] == '\\' { + // skip next byte + i++ + } else if s[i] == c { + return i + } + } + return -1 +} + +// findMatchedClosingAltIndex finds the matching `}` for a `{`. +func findMatchedClosingAltIndex(s string, allowEscaping bool) int { + return findMatchedClosingSymbolsIndex(s, allowEscaping, '{', '}', 1) +} + +// findMatchedClosingBracketIndex finds the matching `)` for a `(`. +func findMatchedClosingBracketIndex(s string, allowEscaping bool) int { + return findMatchedClosingSymbolsIndex(s, allowEscaping, '(', ')', 0) +} + +// findNextCommaIndex returns the next comma outside nested braces. +func findNextCommaIndex(s string, allowEscaping bool) int { + alts := 1 + l := len(s) + for i := 0; i < l; i++ { + if allowEscaping && s[i] == '\\' { + i++ + } else if s[i] == '{' { + alts++ + } else if s[i] == '}' { + alts-- + } else if s[i] == ',' && alts == 1 { + return i + } + } + return -1 +} + +func findMatchedClosingSymbolsIndex(s string, allowEscaping bool, left, right uint8, begin int) int { + l := len(s) + for i := 0; i < l; i++ { + if allowEscaping && s[i] == '\\' { + i++ + } else if s[i] == left { + begin++ + } else if s[i] == right { + if begin--; begin == 0 { + return i + } + } + } + return -1 +} diff --git a/components/execd/pkg/util/glob/match.go b/components/execd/pkg/util/glob/match.go new file mode 100644 index 0000000..c551d44 --- /dev/null +++ b/components/execd/pkg/util/glob/match.go @@ -0,0 +1,317 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// This code is based on or derived from doublestar +// Copyright (c) 2014 Bob Matcuk +// Licensed under MIT License +// https://github.com/bmatcuk/doublestar/blob/master/LICENSE + +package glob + +import ( + "path/filepath" + "unicode/utf8" + + globutil "github.com/bmatcuk/doublestar/v4" +) + +// PathMatch is filepath.Match compatible but honors doublestar semantics. +func PathMatch(pattern, name string) (bool, error) { + return matchWithSeparator(pattern, name, filepath.Separator, true) +} + +func matchWithSeparator(pattern, name string, separator rune, validate bool) (matched bool, err error) { + return doMatchWithSeparator(pattern, name, separator, validate, -1, -1, -1, -1, 0, 0) +} + +//nolint:gocognit,nestif,gocyclo,maintidx +func doMatchWithSeparator(pattern, name string, separator rune, validate bool, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, patIdx, nameIdx int) (matched bool, err error) { + patLen := len(pattern) + nameLen := len(name) + startOfSegment := true +MATCH: + for nameIdx < nameLen { + if patIdx < patLen { + switch pattern[patIdx] { + case '*': + if patIdx++; patIdx < patLen && pattern[patIdx] == '*' { + // doublestar - must begin with a path separator, otherwise we'll + patIdx++ + if startOfSegment { + if patIdx >= patLen { + // pattern ends in `/**`: return true + return true, nil + } + + // doublestar must also end with a path separator, otherwise we're + patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:]) + if patRune == separator { + patIdx += patRuneLen + + doublestarPatternBacktrack = patIdx + doublestarNameBacktrack = nameIdx + starPatternBacktrack = -1 + starNameBacktrack = -1 + continue + } + } + } + startOfSegment = false + + starPatternBacktrack = patIdx + starNameBacktrack = nameIdx + continue + + case '?': + startOfSegment = false + nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) + if nameRune == separator { + // `?` cannot match the separator + break + } + + patIdx++ + nameIdx += nameRuneLen + continue + + case '[': + startOfSegment = false + if patIdx++; patIdx >= patLen { + // class didn't end + return false, globutil.ErrBadPattern + } + nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) + + matched := false + negate := pattern[patIdx] == '!' || pattern[patIdx] == '^' + if negate { + patIdx++ + } + + if patIdx >= patLen || pattern[patIdx] == ']' { + // class didn't end or empty character class + return false, globutil.ErrBadPattern + } + + last := utf8.MaxRune + for patIdx < patLen && pattern[patIdx] != ']' { + patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:]) + patIdx += patRuneLen + + // match a range + if last < utf8.MaxRune && patRune == '-' && patIdx < patLen && pattern[patIdx] != ']' { + if pattern[patIdx] == '\\' { + // next character is escaped + patIdx++ + } + patRune, patRuneLen = utf8.DecodeRuneInString(pattern[patIdx:]) + patIdx += patRuneLen + + if last <= nameRune && nameRune <= patRune { + matched = true + break + } + + // didn't match range - reset `last` + last = utf8.MaxRune + continue + } + + // not a range - check if the next rune is escaped + if patRune == '\\' { + patRune, patRuneLen = utf8.DecodeRuneInString(pattern[patIdx:]) + patIdx += patRuneLen + } + + // check if the rune matches + if patRune == nameRune { + matched = true + break + } + + // no matches yet + last = patRune + } + + if matched == negate { + // failed to match - if we reached the end of the pattern, that means + if patIdx >= patLen { + return false, globutil.ErrBadPattern + } + break + } + + closingIdx := findUnescapedByteIndex(pattern[patIdx:], ']', true) + if closingIdx == -1 { + // no closing `]` + return false, globutil.ErrBadPattern + } + + patIdx += closingIdx + 1 + nameIdx += nameRuneLen + continue + case '!': + negateIdx := patIdx + // begin index of ( + patIdx++ + closingIdx := findMatchedClosingBracketIndex(pattern[patIdx:], separator != '\\') + if closingIdx == -1 { + return false, globutil.ErrBadPattern + } + closingIdx += patIdx + + result, err := doMatchWithSeparator(pattern[:negateIdx]+pattern[patIdx+1:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, negateIdx, nameIdx) + if err != nil { + return false, err + } else if !result { + return true, nil + } else { + return false, nil + } + case '{': + startOfSegment = false //nolint:ineffassign + beforeIdx := patIdx + patIdx++ + closingIdx := findMatchedClosingAltIndex(pattern[patIdx:], separator != '\\') + if closingIdx == -1 { + // no closing `}` + return false, globutil.ErrBadPattern + } + closingIdx += patIdx + + for { + commaIdx := findNextCommaIndex(pattern[patIdx:closingIdx], separator != '\\') + if commaIdx == -1 { + break + } + commaIdx += patIdx + + result, err := doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:commaIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx) + if result || err != nil { + return result, err + } + + patIdx = commaIdx + 1 + } + return doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx) + + case '\\': + if separator != '\\' { + // next rune is "escaped" in the pattern - literal match + if patIdx++; patIdx >= patLen { + // pattern ended + return false, globutil.ErrBadPattern + } + } + fallthrough + + default: + patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:]) + nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) + if patRune != nameRune { + if separator != '\\' && patIdx > 0 && pattern[patIdx-1] == '\\' { + // if this rune was meant to be escaped, we need to move patIdx + patIdx-- + } + break + } + + patIdx += patRuneLen + nameIdx += nameRuneLen + startOfSegment = patRune == separator + continue + } + } + + if starPatternBacktrack >= 0 { + // `*` backtrack, but only if the `name` rune isn't the separator + nameRune, nameRuneLen := utf8.DecodeRuneInString(name[starNameBacktrack:]) + if nameRune != separator { + starNameBacktrack += nameRuneLen + patIdx = starPatternBacktrack + nameIdx = starNameBacktrack + startOfSegment = false + continue + } + } + + if doublestarPatternBacktrack >= 0 { + // `**` backtrack, advance `name` past next separator + nameIdx = doublestarNameBacktrack + for nameIdx < nameLen { + nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) + nameIdx += nameRuneLen + if nameRune == separator { + doublestarNameBacktrack = nameIdx + patIdx = doublestarPatternBacktrack + startOfSegment = true + continue MATCH + } + } + } + + if validate && patIdx < patLen && !isValidPattern(pattern[patIdx:], separator) { + return false, globutil.ErrBadPattern + } + return false, nil + } + + if nameIdx < nameLen { + // we reached the end of `pattern` before the end of `name` + return false, nil + } + + // we've reached the end of `name`; we've successfully matched if we've also + return isZeroLengthPattern(pattern[patIdx:], separator) +} + +// nolint:nakedret +func isZeroLengthPattern(pattern string, separator rune) (ret bool, err error) { + // `/**` is a special case - a pattern such as `path/to/a/**` *should* match + if pattern == "" || pattern == "*" || pattern == "**" || pattern == string(separator)+"**" { + return true, nil + } + + if pattern[0] == '{' { + closingIdx := findMatchedClosingAltIndex(pattern[1:], separator != '\\') + if closingIdx == -1 { + // no closing '}' + return false, globutil.ErrBadPattern + } + closingIdx += 1 + + patIdx := 1 + for { + commaIdx := findNextCommaIndex(pattern[patIdx:closingIdx], separator != '\\') + if commaIdx == -1 { + break + } + commaIdx += patIdx + + ret, err = isZeroLengthPattern(pattern[patIdx:commaIdx]+pattern[closingIdx+1:], separator) + if ret || err != nil { + return + } + + patIdx = commaIdx + 1 + } + return isZeroLengthPattern(pattern[patIdx:closingIdx]+pattern[closingIdx+1:], separator) + } + + // no luck - validate the rest of the pattern + if !isValidPattern(pattern, separator) { + return false, globutil.ErrBadPattern + } + return false, nil +} diff --git a/components/execd/pkg/util/glob/match_benchmark_test.go b/components/execd/pkg/util/glob/match_benchmark_test.go new file mode 100644 index 0000000..cf1943b --- /dev/null +++ b/components/execd/pkg/util/glob/match_benchmark_test.go @@ -0,0 +1,33 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package glob + +import ( + "path/filepath" + "testing" +) + +func BenchmarkPathMatch(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + for _, tt := range matchTests { + if tt.isStandard && tt.testOnDisk { + pattern := filepath.FromSlash(tt.pattern) + testPath := filepath.FromSlash(tt.testPath) + PathMatch(pattern, testPath) + } + } + } +} diff --git a/components/execd/pkg/util/glob/match_test.go b/components/execd/pkg/util/glob/match_test.go new file mode 100644 index 0000000..4b3ba42 --- /dev/null +++ b/components/execd/pkg/util/glob/match_test.go @@ -0,0 +1,299 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// This code is based on or derived from doublestar +// Copyright (c) 2014 Bob Matcuk +// Licensed under MIT License +// https://github.com/bmatcuk/doublestar/blob/master/LICENSE + +package glob + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + + globutil "github.com/bmatcuk/doublestar/v4" +) + +type MatchTest struct { + pattern, testPath string + shouldMatch bool + shouldMatchGlob bool + expectedErr error + expectIOErr bool + expectPatternNotExist bool + isStandard bool + testOnDisk bool + numResults int + winNumResults int +} + +// Tests which contain escapes and symlinks will not work on Windows +var onWindows = runtime.GOOS == "windows" + +var matchTests = []MatchTest{ + {"", "", true, false, nil, true, false, true, true, 0, 0}, + {"*", "", true, true, nil, false, false, true, false, 0, 0}, + {"*", "/", false, false, nil, false, false, true, false, 0, 0}, + {"/*", "/", true, true, nil, false, false, true, false, 0, 0}, + {"/*", "/debug/", false, false, nil, false, false, true, false, 0, 0}, + {"/*", "//", false, false, nil, false, false, true, false, 0, 0}, + {"abc", "abc", true, true, nil, false, false, true, true, 1, 1}, + {"*", "abc", true, true, nil, false, false, true, true, 22, 17}, + {"*c", "abc", true, true, nil, false, false, true, true, 2, 2}, + {"*/", "a/", true, true, nil, false, false, true, false, 0, 0}, + {"a*", "a", true, true, nil, false, false, true, true, 9, 9}, + {"a*", "abc", true, true, nil, false, false, true, true, 9, 9}, + {"a*", "ab/c", false, false, nil, false, false, true, true, 9, 9}, + {"a*/b", "abc/b", true, true, nil, false, false, true, true, 2, 2}, + {"a*/b", "a/c/b", false, false, nil, false, false, true, true, 2, 2}, + {"a*/c/", "a/b", false, false, nil, false, false, false, true, 1, 1}, + {"a*b*c*d*e*", "axbxcxdxe", true, true, nil, false, false, true, true, 3, 3}, + {"a*b*c*d*e*/f", "axbxcxdxe/f", true, true, nil, false, false, true, true, 2, 2}, + {"a*b*c*d*e*/f", "axbxcxdxexxx/f", true, true, nil, false, false, true, true, 2, 2}, + {"a*b*c*d*e*/f", "axbxcxdxe/xxx/f", false, false, nil, false, false, true, true, 2, 2}, + {"a*b*c*d*e*/f", "axbxcxdxexxx/fff", false, false, nil, false, false, true, true, 2, 2}, + {"a*b?c*x", "abxbbxdbxebxczzx", true, true, nil, false, false, true, true, 2, 2}, + {"a*b?c*x", "abxbbxdbxebxczzy", false, false, nil, false, false, true, true, 2, 2}, + {"ab[c]", "abc", true, true, nil, false, false, true, true, 1, 1}, + {"ab[b-d]", "abc", true, true, nil, false, false, true, true, 1, 1}, + {"ab[e-g]", "abc", false, false, nil, false, false, true, true, 0, 0}, + {"ab[^c]", "abc", false, false, nil, false, false, true, true, 0, 0}, + {"ab[^b-d]", "abc", false, false, nil, false, false, true, true, 0, 0}, + {"ab[^e-g]", "abc", true, true, nil, false, false, true, true, 1, 1}, + {"a\\*b", "ab", false, false, nil, false, true, true, !onWindows, 0, 0}, + {"a?b", "a☺b", true, true, nil, false, false, true, true, 1, 1}, + {"a[^a]b", "a☺b", true, true, nil, false, false, true, true, 1, 1}, + {"a[!a]b", "a☺b", true, true, nil, false, false, false, true, 1, 1}, + {"a???b", "a☺b", false, false, nil, false, false, true, true, 0, 0}, + {"a[^a][^a][^a]b", "a☺b", false, false, nil, false, false, true, true, 0, 0}, + {"[a-ζ]*", "α", true, true, nil, false, false, true, true, 20, 17}, + {"*[a-ζ]", "A", false, false, nil, false, false, true, true, 20, 17}, + {"a?b", "a/b", false, false, nil, false, false, true, true, 1, 1}, + {"a*b", "a/b", false, false, nil, false, false, true, true, 1, 1}, + {"[\\]a]", "]", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"[\\-]", "-", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"[x\\-]", "x", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"[x\\-]", "-", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"[x\\-]", "z", false, false, nil, false, false, true, !onWindows, 2, 2}, + {"[\\-x]", "x", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"[\\-x]", "-", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"[\\-x]", "a", false, false, nil, false, false, true, !onWindows, 2, 2}, + {"[]a]", "]", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + // doublestar, like bash, allows these when path.Match() does not + {"[-]", "-", true, true, nil, false, false, false, !onWindows, 1, 0}, + {"[x-]", "x", true, true, nil, false, false, false, true, 2, 1}, + {"[x-]", "-", true, true, nil, false, false, false, !onWindows, 2, 1}, + {"[x-]", "z", false, false, nil, false, false, false, true, 2, 1}, + {"[-x]", "x", true, true, nil, false, false, false, true, 2, 1}, + {"[-x]", "-", true, true, nil, false, false, false, !onWindows, 2, 1}, + {"[-x]", "a", false, false, nil, false, false, false, true, 2, 1}, + {"[a-b-d]", "a", true, true, nil, false, false, false, true, 3, 2}, + {"[a-b-d]", "b", true, true, nil, false, false, false, true, 3, 2}, + {"[a-b-d]", "-", true, true, nil, false, false, false, !onWindows, 3, 2}, + {"[a-b-d]", "c", false, false, nil, false, false, false, true, 3, 2}, + {"[a-b-x]", "x", true, true, nil, false, false, false, true, 4, 3}, + {"\\", "a", false, false, globutil.ErrBadPattern, false, false, true, !onWindows, 0, 0}, + {"[", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"[^", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"[^bc", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"a[", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"a[", "ab", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"ad[", "ab", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"*x", "xxx", true, true, nil, false, false, true, true, 4, 4}, + {"[abc]", "b", true, true, nil, false, false, true, true, 3, 3}, + {"**", "", true, true, nil, false, false, false, false, 38, 38}, + {"a/**", "a", true, false, nil, false, false, false, true, 7, 7}, + {"a/**", "a/", true, true, nil, false, false, false, false, 7, 7}, + {"a/**/", "a/", true, true, nil, false, false, false, false, 4, 4}, + {"a/**", "a/b", true, true, nil, false, false, false, true, 7, 7}, + {"a/**", "a/b/c", true, true, nil, false, false, false, true, 7, 7}, + {"**/c", "c", true, true, nil, !onWindows, false, false, true, 5, 4}, + {"**/c", "b/c", true, true, nil, !onWindows, false, false, true, 5, 4}, + {"**/c", "a/b/c", true, true, nil, !onWindows, false, false, true, 5, 4}, + {"**/c", "a/b", false, false, nil, !onWindows, false, false, true, 5, 4}, + {"**/c", "abcd", false, false, nil, !onWindows, false, false, true, 5, 4}, + {"**/c", "a/abc", false, false, nil, !onWindows, false, false, true, 5, 4}, + {"a/**/b", "a/b", true, true, nil, false, false, false, true, 2, 2}, + {"a/**/c", "a/b/c", true, true, nil, false, false, false, true, 2, 2}, + {"a/**/d", "a/b/c/d", true, true, nil, false, false, false, true, 1, 1}, + {"a/\\**", "a/b/c", false, false, nil, false, false, false, !onWindows, 0, 0}, + {"a/\\[*\\]", "a/bc", false, false, nil, false, false, true, !onWindows, 0, 0}, + // this fails the FilepathGlob test on Windows + {"a/b/c", "a/b//c", false, false, nil, false, false, true, !onWindows, 1, 1}, + // odd: Glob + filepath.Glob return results + {"a/", "a", false, false, nil, false, false, true, false, 0, 0}, + {"ab{c,d}", "abc", true, true, nil, false, true, false, true, 1, 1}, + {"ab{c,d,*}", "abcde", true, true, nil, false, true, false, true, 5, 5}, + {"ab{c,d}[", "abcd", false, false, globutil.ErrBadPattern, false, false, false, true, 0, 0}, + {"a{,bc}", "a", true, true, nil, false, false, false, true, 2, 2}, + {"a{,bc}", "abc", true, true, nil, false, false, false, true, 2, 2}, + {"a/{b/c,c/b}", "a/b/c", true, true, nil, false, false, false, true, 2, 2}, + {"a/{b/c,c/b}", "a/c/b", true, true, nil, false, false, false, true, 2, 2}, + {"a/a*{b,c}", "a/abc", true, true, nil, false, false, false, true, 1, 1}, + {"{a/{b,c},abc}", "a/b", true, true, nil, false, false, false, true, 3, 3}, + {"{a/{b,c},abc}", "a/c", true, true, nil, false, false, false, true, 3, 3}, + {"{a/{b,c},abc}", "abc", true, true, nil, false, false, false, true, 3, 3}, + {"{a/{b,c},abc}", "a/b/c", false, false, nil, false, false, false, true, 3, 3}, + {"{a/ab*}", "a/abc", true, true, nil, false, false, false, true, 1, 1}, + {"{a/*}", "a/b", true, true, nil, false, false, false, true, 3, 3}, + {"{a/abc}", "a/abc", true, true, nil, false, false, false, true, 1, 1}, + {"{a/b,a/c}", "a/c", true, true, nil, false, false, false, true, 2, 2}, + {"abc/**", "abc/b", true, true, nil, false, false, false, true, 3, 3}, + {"**/abc", "abc", true, true, nil, !onWindows, false, false, true, 2, 2}, + {"abc**", "abc/b", false, false, nil, false, false, false, true, 3, 3}, + {"**/*.txt", "abc/ßtestß.txt", true, true, nil, !onWindows, false, false, true, 1, 1}, + {"**/ß*", "abc/ßtestß.txt", true, true, nil, !onWindows, false, false, true, 1, 1}, + {"**/{a,b}", "a/b", true, true, nil, !onWindows, false, false, true, 5, 5}, + // unfortunately, io/fs can't handle this, so neither can Glob =( + {"broken-symlink", "broken-symlink", true, true, nil, false, false, true, false, 1, 1}, + {"broken-symlink/*", "a", false, false, nil, false, true, true, true, 0, 0}, + {"broken*/*", "a", false, false, nil, false, false, true, true, 0, 0}, + {"working-symlink/c/*", "working-symlink/c/d", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"working-sym*/*", "working-symlink/c", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"b/**/f", "b/symlink-dir/f", true, true, nil, false, false, false, !onWindows, 2, 2}, + {"*/symlink-dir/*", "b/symlink-dir/f", true, true, nil, !onWindows, false, true, !onWindows, 2, 2}, + {"e/**", "e/**", true, true, nil, false, false, false, !onWindows, 11, 6}, + {"e/**", "e/*", true, true, nil, false, false, false, !onWindows, 11, 6}, + {"e/**", "e/?", true, true, nil, false, false, false, !onWindows, 11, 6}, + {"e/**", "e/[", true, true, nil, false, false, false, true, 11, 6}, + {"e/**", "e/]", true, true, nil, false, false, false, true, 11, 6}, + {"e/**", "e/[]", true, true, nil, false, false, false, true, 11, 6}, + {"e/**", "e/{", true, true, nil, false, false, false, true, 11, 6}, + {"e/**", "e/}", true, true, nil, false, false, false, true, 11, 6}, + {"e/**", "e/\\", true, true, nil, false, false, false, !onWindows, 11, 6}, + {"e/*", "e/*", true, true, nil, false, false, true, !onWindows, 10, 5}, + {"e/?", "e/?", true, true, nil, false, false, true, !onWindows, 7, 4}, + {"e/?", "e/*", true, true, nil, false, false, true, !onWindows, 7, 4}, + {"e/?", "e/[", true, true, nil, false, false, true, true, 7, 4}, + {"e/?", "e/]", true, true, nil, false, false, true, true, 7, 4}, + {"e/?", "e/{", true, true, nil, false, false, true, true, 7, 4}, + {"e/?", "e/}", true, true, nil, false, false, true, true, 7, 4}, + {"e/\\[", "e/[", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/[", "e/[", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, + {"e/]", "e/]", true, true, nil, false, false, true, true, 1, 1}, + {"e/\\]", "e/]", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/\\{", "e/{", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/\\}", "e/}", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/[\\*\\?]", "e/*", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"e/[\\*\\?]", "e/?", true, true, nil, false, false, true, !onWindows, 2, 2}, + {"e/[\\*\\?]", "e/**", false, false, nil, false, false, true, !onWindows, 2, 2}, + {"e/[\\*\\?]?", "e/**", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/{\\*,\\?}", "e/*", true, true, nil, false, false, false, !onWindows, 2, 2}, + {"e/{\\*,\\?}", "e/?", true, true, nil, false, false, false, !onWindows, 2, 2}, + {"e/\\*", "e/*", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/\\?", "e/?", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"e/\\?", "e/**", false, false, nil, false, false, true, !onWindows, 1, 1}, + {"*\\}", "}", true, true, nil, false, false, true, !onWindows, 1, 1}, + {"nonexistent-path", "a", false, false, nil, false, true, true, true, 0, 0}, + {"nonexistent-path/", "a", false, false, nil, false, true, true, true, 0, 0}, + {"nonexistent-path/file", "a", false, false, nil, false, true, true, true, 0, 0}, + {"nonexistent-path/*", "a", false, false, nil, false, true, true, true, 0, 0}, + {"nonexistent-path/**", "a", false, false, nil, false, true, true, true, 0, 0}, + {"nopermission/*", "nopermission/file", true, false, nil, true, false, true, !onWindows, 0, 0}, + {"nopermission/dir/", "nopermission/dir", false, false, nil, true, false, true, !onWindows, 0, 0}, + {"nopermission/file", "nopermission/file", true, false, nil, true, false, true, !onWindows, 0, 0}, + {"node_modules/!(.cache)/**", "node_modules/others/file.txt", true, true, nil, false, false, false, !onWindows, 0, 0}, + {"node_modules/!(.cache)/**", "node_modules/.cache/file.txt", false, false, nil, false, false, false, !onWindows, 0, 0}, + {"node_modules/!(.cache)/**", "node_modules/file.txt", true, false, nil, false, false, false, !onWindows, 0, 0}, + {"node_modules/!(.cache)/**", "node_modules/others/others/file.txt", true, true, nil, false, false, false, !onWindows, 0, 0}, +} + +func TestValidatePattern(t *testing.T) { + for idx, tt := range matchTests { + testValidatePatternWith(t, idx, tt) + } +} + +func testValidatePatternWith(t *testing.T, idx int, tt MatchTest) { + defer func() { + if r := recover(); r != nil { + t.Errorf("#%v. Validate(%#q) panicked: %#v", idx, tt.pattern, r) + } + }() + + result := isValidPattern(tt.pattern, '/') + if result != (tt.expectedErr == nil) { + t.Errorf("#%v. ValidatePattern(%#q) = %v want %v", idx, tt.pattern, result, !result) + } +} + +func TestPathMatch(t *testing.T) { + for idx, tt := range matchTests { + // Even though we aren't actually matching paths on disk, we are using + if tt.testOnDisk { + testPathMatchWith(t, idx, tt) + } + } +} + +func testPathMatchWith(t *testing.T, idx int, tt MatchTest) { + defer func() { + if r := recover(); r != nil { + t.Errorf("#%v. Match(%#q, %#q) panicked: %#v", idx, tt.pattern, tt.testPath, r) + } + }() + + pattern := filepath.FromSlash(tt.pattern) + testPath := filepath.FromSlash(tt.testPath) + ok, err := PathMatch(pattern, testPath) + if ok != tt.shouldMatch || err != tt.expectedErr { + t.Errorf("#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr) + } + + if tt.isStandard { + stdOk, stdErr := filepath.Match(pattern, testPath) + if ok != stdOk || !compareErrors(err, stdErr) { + t.Errorf("#%v. PathMatch(%#q, %#q) != filepath.Match(...). Got %v, %v want %v, %v", idx, pattern, testPath, ok, err, stdOk, stdErr) + } + } +} + +func TestPathMatchFake(t *testing.T) { + // This test fakes that our path separator is `\\` so we can test what it + if onWindows { + return + } + + for idx, tt := range matchTests { + // Even though we aren't actually matching paths on disk, we are using + if tt.testOnDisk && !strings.Contains(tt.pattern, "\\") { + testPathMatchFakeWith(t, idx, tt) + } + } +} + +func testPathMatchFakeWith(t *testing.T, idx int, tt MatchTest) { + defer func() { + if r := recover(); r != nil { + t.Errorf("#%v. Match(%#q, %#q) panicked: %#v", idx, tt.pattern, tt.testPath, r) + } + }() + + pattern := strings.ReplaceAll(tt.pattern, "/", "\\") + testPath := strings.ReplaceAll(tt.testPath, "/", "\\") + ok, err := matchWithSeparator(pattern, testPath, '\\', true) + if ok != tt.shouldMatch || err != tt.expectedErr { + t.Errorf("#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr) + } +} + +func compareErrors(a, b error) bool { + if a == nil { + return b == nil + } + return b != nil +} diff --git a/components/execd/pkg/util/glob/pattern.go b/components/execd/pkg/util/glob/pattern.go new file mode 100644 index 0000000..97f8849 --- /dev/null +++ b/components/execd/pkg/util/glob/pattern.go @@ -0,0 +1,69 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package glob + +// isValidPattern checks whether a glob pattern is well-formed. +// +//nolint:gocognit +func isValidPattern(s string, separator rune) bool { + altDepth := 0 + l := len(s) +VALIDATE: + for i := 0; i < l; i++ { + switch s[i] { + case '\\': + if separator != '\\' { + if i++; i >= l { + return false + } + } + continue + + case '[': + if i++; i >= l { + return false + } + if s[i] == '^' || s[i] == '!' { + i++ + } + if i >= l || s[i] == ']' { + return false + } + + for ; i < l; i++ { + if separator != '\\' && s[i] == '\\' { + i++ + } else if s[i] == ']' { + continue VALIDATE + } + } + + return false + + case '{': + altDepth++ + continue + + case '}': + if altDepth == 0 { + return false + } + altDepth-- + continue + } + } + + return altDepth == 0 +} diff --git a/components/execd/pkg/util/pathutil/path.go b/components/execd/pkg/util/pathutil/path.go new file mode 100644 index 0000000..1762a19 --- /dev/null +++ b/components/execd/pkg/util/pathutil/path.go @@ -0,0 +1,111 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pathutil + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var envVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`) + +func envMapFromProcessAndOverrides(envOverrides map[string]string) map[string]string { + out := make(map[string]string, len(envOverrides)+16) + for _, kv := range os.Environ() { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + continue + } + out[parts[0]] = parts[1] + } + for k, v := range envOverrides { + out[k] = v + } + return out +} + +func validateEnvVars(path string, env map[string]string) error { + matches := envVarPattern.FindAllStringSubmatch(path, -1) + if len(matches) == 0 { + return nil + } + + missingSet := make(map[string]struct{}) + for _, m := range matches { + name := m[1] + if name == "" { + name = m[2] + } + if _, ok := env[name]; !ok { + missingSet[name] = struct{}{} + } + } + if len(missingSet) == 0 { + return nil + } + + missing := make([]string, 0, len(missingSet)) + for name := range missingSet { + missing = append(missing, name) + } + sort.Strings(missing) + return fmt.Errorf("path references undefined environment variables: %s", strings.Join(missing, ",")) +} + +// ExpandPathWithEnv expands environment variables and a leading "~" to user home. +// Environment resolution uses process env overlaid by envOverrides. +func ExpandPathWithEnv(path string, envOverrides map[string]string) (string, error) { + if path == "" { + return "", nil + } + env := envMapFromProcessAndOverrides(envOverrides) + if err := validateEnvVars(path, env); err != nil { + return "", err + } + + expanded := os.Expand(path, func(key string) string { + return env[key] + }) + if expanded == "~" || strings.HasPrefix(expanded, "~/") || strings.HasPrefix(expanded, `~\`) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if expanded == "~" { + return home, nil + } + return filepath.Join(home, expanded[2:]), nil + } + + return expanded, nil +} + +// ExpandPath expands environment variables and a leading "~" to user home. +// It supports "~", "~/" and "~\" prefixes. +func ExpandPath(path string) (string, error) { + return ExpandPathWithEnv(path, nil) +} + +func ExpandAbsPath(path string) (string, error) { + expanded, err := ExpandPathWithEnv(path, nil) + if err != nil { + return "", err + } + return filepath.Abs(expanded) +} diff --git a/components/execd/pkg/util/pathutil/path_test.go b/components/execd/pkg/util/pathutil/path_test.go new file mode 100644 index 0000000..cf05f02 --- /dev/null +++ b/components/execd/pkg/util/pathutil/path_test.go @@ -0,0 +1,87 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pathutil + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExpandPath_HomeAndEnv(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("BASE_DIR", "project") + + got, err := ExpandPath("~/docs/$BASE_DIR") + require.NoError(t, err) + require.Equal(t, filepath.Join(home, "docs", "project"), got) +} + +func TestExpandPath_Empty(t *testing.T) { + got, err := ExpandPath("") + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestExpandPath_EnvVarInMiddle(t *testing.T) { + t.Setenv("MID", "segment") + + got, err := ExpandPath("prefix/$MID/suffix") + require.NoError(t, err) + require.Equal(t, filepath.Join("prefix", "segment", "suffix"), got) +} + +func TestExpandPathWithEnv_RequestOverrideHasHigherPriority(t *testing.T) { + t.Setenv("WORKDIR", "from-process") + + got, err := ExpandPathWithEnv("base/$WORKDIR", map[string]string{ + "WORKDIR": "from-request", + }) + require.NoError(t, err) + require.Equal(t, filepath.Join("base", "from-request"), got) +} + +func TestExpandPathWithEnv_CanResolveVarOnlyInOverride(t *testing.T) { + got, err := ExpandPathWithEnv("$WORKDIR/tmp", map[string]string{ + "WORKDIR": "/tmp/ws", + }) + require.NoError(t, err) + require.Equal(t, filepath.Join("/tmp/ws", "tmp"), got) +} + +func TestExpandPath_UndefinedEnvVarInMiddleReturnsError(t *testing.T) { + got, err := ExpandPath("prefix/$NOT_SET/suffix") + require.Error(t, err) + require.Contains(t, err.Error(), "NOT_SET") + require.Equal(t, "", got) +} + +func TestExpandPath_UndefinedEnvVarReturnsError(t *testing.T) { + got, err := ExpandPath("$NOT_SET/tmp") + require.Error(t, err) + require.Contains(t, err.Error(), "NOT_SET") + require.Equal(t, "", got) +} + +func TestExpandPath_UndefinedEnvVarOnlyReturnsError(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + got, err := ExpandPath("$NOT_SET") + require.Error(t, err) + require.Contains(t, err.Error(), "NOT_SET") + require.Equal(t, "", got) +} diff --git a/components/execd/pkg/vfs/vfs.go b/components/execd/pkg/vfs/vfs.go new file mode 100644 index 0000000..e5fb5a9 --- /dev/null +++ b/components/execd/pkg/vfs/vfs.go @@ -0,0 +1,39 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package vfs defines a virtual filesystem interface used by file handlers. +package vfs + +import ( + "io" + "os" +) + +// FS abstracts filesystem operations so that both the host OS and +// overlay (MergedView) can be used interchangeably by file handlers. +type FS interface { + Stat(path string) (os.FileInfo, error) + ReadFile(path string) ([]byte, error) + WriteFile(path string, data []byte, perm os.FileMode) error + WriteFileReader(path string, r io.Reader, perm os.FileMode) (int64, error) + Remove(path string) error + RemoveAll(path string) error + MkdirAll(path string, perm os.FileMode) error + Rename(oldPath, newPath string) error + Chmod(path string, mode os.FileMode) error + ReadDir(path string) ([]os.DirEntry, error) + Open(path string) (*os.File, error) + Search(root, pattern string) ([]string, error) + ReplaceContent(path, old, newStr string) error +} diff --git a/components/execd/pkg/web/controller/basic.go b/components/execd/pkg/web/controller/basic.go new file mode 100644 index 0000000..fd79c98 --- /dev/null +++ b/components/execd/pkg/web/controller/basic.go @@ -0,0 +1,68 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "net/http" + "strconv" + "sync" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +type basicController struct { + ctx *gin.Context + sseSetupOnce sync.Once + chunkWriter sync.Mutex +} + +func newBasicController(ctx *gin.Context) *basicController { + return &basicController{ctx: ctx} +} + +func (c *basicController) RespondError(status int, code model.ErrorCode, message ...string) { + resp := model.ErrorResponse{ + Code: code, + Message: "", + } + if len(message) > 0 { + resp.Message = message[0] + } + c.ctx.JSON(status, resp) +} + +func (c *basicController) RespondSuccess(data any) { + if data == nil { + c.ctx.Status(http.StatusOK) + return + } + c.ctx.JSON(http.StatusOK, data) +} + +func (c *basicController) QueryInt64(query string, defaultValue int64) int64 { + val, err := strconv.ParseInt(query, 10, 64) + if err != nil { + return defaultValue + } + return val +} + +func (c *basicController) bindJSON(target any) error { + decoder := json.NewDecoder(c.ctx.Request.Body) + return decoder.Decode(target) +} diff --git a/components/execd/pkg/web/controller/basic_test.go b/components/execd/pkg/web/controller/basic_test.go new file mode 100644 index 0000000..545cd09 --- /dev/null +++ b/components/execd/pkg/web/controller/basic_test.go @@ -0,0 +1,104 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alibaba/opensandbox/execd/pkg/web/model" + "github.com/stretchr/testify/require" +) + +func TestBasicControllerRespondSuccess(t *testing.T) { + ctx, rec := newTestContext(http.MethodGet, "/", nil) + ctrl := &basicController{ctx: ctx} + + payload := map[string]string{"status": "ok"} + ctrl.RespondSuccess(payload) + + require.Equal(t, http.StatusOK, rec.Code) + var resp map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, "ok", resp["status"]) +} + +func TestBasicControllerRespondError(t *testing.T) { + ctx, rec := newTestContext(http.MethodGet, "/", nil) + ctrl := &basicController{ctx: ctx} + + ctrl.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "boom") + + require.Equal(t, http.StatusBadRequest, rec.Code) + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeInvalidRequest, resp.Code) + require.Equal(t, "boom", resp.Message) +} + +func setupBasicController(method string) (*basicController, *httptest.ResponseRecorder) { + ctx, w := newTestContext(method, "/", nil) + ctrl := &basicController{ctx: ctx} + return ctrl, w +} + +func TestRespondSuccessWritesPayload(t *testing.T) { + ctrl, w := setupBasicController(http.MethodGet) + + payload := map[string]string{"status": "ok"} + ctrl.RespondSuccess(payload) + + require.Equal(t, http.StatusOK, w.Code) + var got map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, "ok", got["status"]) +} + +func TestRespondErrorAddsCodeAndMessage(t *testing.T) { + ctrl, w := setupBasicController(http.MethodGet) + + ctrl.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "invalid payload") + + require.Equal(t, http.StatusBadRequest, w.Code) + var got model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) + require.Equal(t, model.ErrorCodeInvalidRequest, got.Code) + require.Equal(t, "invalid payload", got.Message) +} + +func TestQueryInt64(t *testing.T) { + ctrl := &basicController{} + + tests := []struct { + name string + query string + def int64 + expected int64 + }{ + {name: "valid number", query: "42", def: 0, expected: 42}, + {name: "empty uses default", query: "", def: 5, expected: 5}, + {name: "invalid uses default", query: "not-a-number", def: -1, expected: -1}, + {name: "negative number", query: "-10", def: 0, expected: -10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ctrl.QueryInt64(tt.query, tt.def) + require.Equalf(t, tt.expected, got, "QueryInt64(%q, %d)", tt.query, tt.def) + }) + } +} diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go new file mode 100644 index 0000000..b700b11 --- /dev/null +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -0,0 +1,509 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/flag" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/telemetry" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +var codeRunner codeExecutionRunner + +func InitCodeRunner() *runtime.Controller { + ctrl := runtime.NewController(flag.JupyterServerHost, flag.JupyterServerToken) + codeRunner = ctrl + return ctrl +} + +// CodeInterpretingController handles code execution entrypoints. +type CodeInterpretingController struct { + *basicController +} + +type codeExecutionRunner interface { + CreateContext(req *runtime.CreateContextRequest) (string, error) + Execute(request *runtime.ExecuteCodeRequest) error + GetContext(session string) (runtime.CodeContext, error) + GetCommandStatus(session string) (*runtime.CommandStatus, error) + ListContext(language string) ([]runtime.CodeContext, error) + DeleteLanguageContext(language runtime.Language) error + DeleteContext(session string) error + CreateBashSession(req *runtime.CreateContextRequest) (string, error) + RunInBashSession(ctx context.Context, req *runtime.ExecuteCodeRequest) error + SeekBackgroundCommandOutput(session string, cursor int64) ([]byte, int64, error) + DeleteBashSession(sessionID string) error + Interrupt(sessionID string) error + CreatePTYSession(id, cwd, command string) (runtime.PTYSession, error) + GetPTYSession(id string) runtime.PTYSession + DeletePTYSession(id string) error + GetPTYSessionStatus(id string) (bool, int64, error) +} + +func NewCodeInterpretingController(ctx *gin.Context) *CodeInterpretingController { + return &CodeInterpretingController{ + basicController: newBasicController(ctx), + } +} + +// CreateContext creates a new code execution context. +func (c *CodeInterpretingController) CreateContext() { + var request model.CodeContextRequest + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + session, err := codeRunner.CreateContext(&runtime.CreateContextRequest{ + Language: runtime.Language(request.Language), + Cwd: request.Cwd, + }) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error creating code context. %v", err), + ) + return + } + + resp := model.CodeContext{ + ID: session, + CodeContextRequest: request, + } + c.RespondSuccess(resp) +} + +// InterruptCode interrupts the execution of running code in a session. +func (c *CodeInterpretingController) InterruptCode() { + c.interrupt() +} + +// RunCode executes code in a context and streams output via SSE. +func (c *CodeInterpretingController) RunCode() { + var request model.RunCodeRequest + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + err := request.Validate() + if err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid request, validation error %v", err), + ) + return + } + + ctx, cancel := context.WithCancel(c.ctx.Request.Context()) + defer cancel() + execStart := time.Now() + var recordOnce sync.Once + recordExecution := func(result string) { + recordOnce.Do(func() { + telemetry.RecordExecutionDuration( + ctx, + "run_code", + result, + float64(time.Since(execStart))/float64(time.Millisecond), + ) + }) + } + runCodeRequest := c.buildExecuteCodeRequest(request) + eventsHandler := c.setServerEventsHandler(ctx) + + // completeCh is closed when OnExecuteComplete fires, meaning the final SSE + // event has been written and flushed. We only wait for this callback as a + // safety check and then return immediately to avoid fixed tail latency. + completeCh := make(chan struct{}) + var completeOnce sync.Once + signalComplete := func() { + completeOnce.Do(func() { + close(completeCh) + }) + } + origComplete := eventsHandler.OnExecuteComplete + eventsHandler.OnExecuteComplete = func(executionTime time.Duration) { + origComplete(executionTime) + recordExecution("success") + signalComplete() + } + origError := eventsHandler.OnExecuteError + eventsHandler.OnExecuteError = func(err *execute.ErrorOutput) { + origError(err) + recordExecution("failure") + signalComplete() + } + runCodeRequest.Hooks = eventsHandler + + // SSE headers are committed lazily on the first event write + // (see writeSingleEvent), so a synchronous error from Execute below can + // still be surfaced as a structured JSON error response. + err = codeRunner.Execute(runCodeRequest) + if err != nil { + recordExecution("failure") + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error running codes %v", err), + ) + return + } + + waitForExecutionComplete(ctx, completeCh) +} + +// GetContext returns a specific code context by id. +func (c *CodeInterpretingController) GetContext() { + contextID := c.ctx.Param("contextId") + if contextID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'contextId'", + ) + return + } + + codeContext, err := codeRunner.GetContext(contextID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("context %s not found", contextID), + ) + return + } + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error getting code context %s. %v", contextID, err), + ) + return + } + c.RespondSuccess(codeContext) +} + +// ListContexts returns active code contexts, optionally filtered by language. +func (c *CodeInterpretingController) ListContexts() { + language := c.ctx.Query("language") + + contexts, err := codeRunner.ListContext(language) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + err.Error(), + ) + return + } + + c.RespondSuccess(contexts) +} + +// DeleteContextsByLanguage deletes all contexts for a given language. +func (c *CodeInterpretingController) DeleteContextsByLanguage() { + language := c.ctx.Query("language") + if language == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'language'", + ) + return + } + + err := codeRunner.DeleteLanguageContext(runtime.Language(language)) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting code context %s. %v", language, err), + ) + return + } + + c.RespondSuccess(nil) +} + +// DeleteContext deletes a specific code context by id. +func (c *CodeInterpretingController) DeleteContext() { + contextID := c.ctx.Param("contextId") + if contextID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'contextId'", + ) + return + } + + err := codeRunner.DeleteContext(contextID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("context %s not found", contextID), + ) + return + } else { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting code context %s. %v", contextID, err), + ) + return + } + } + + c.RespondSuccess(nil) +} + +// CreateSession creates a new bash session (create_session API). +// An empty body is allowed and is treated as default options (no cwd override). +func (c *CodeInterpretingController) CreateSession() { + var request model.CreateSessionRequest + if err := c.bindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request. %v", err), + ) + return + } + + sessionID, err := codeRunner.CreateBashSession(&runtime.CreateContextRequest{ + Cwd: request.Cwd, + }) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error creating session. %v", err), + ) + return + } + + c.RespondSuccess(model.CreateSessionResponse{SessionID: sessionID}) +} + +// RunInSession runs a command in an existing bash session and streams output via SSE (run_in_session API). +func (c *CodeInterpretingController) RunInSession() { + sessionID := c.ctx.Param("sessionId") + if sessionID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + var request model.RunInSessionRequest + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request. %v", err), + ) + return + } + if err := request.Validate(); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid request. %v", err), + ) + return + } + + timeout := time.Duration(request.Timeout) * time.Millisecond + runReq := &runtime.ExecuteCodeRequest{ + Language: runtime.Bash, + Context: sessionID, + Code: request.Command, + Cwd: request.Cwd, + Timeout: timeout, + } + ctx, cancel := context.WithCancel(c.ctx.Request.Context()) + defer cancel() + execStart := time.Now() + var recordOnce sync.Once + recordExecution := func(result string) { + recordOnce.Do(func() { + telemetry.RecordExecutionDuration( + ctx, + "run_in_session", + result, + float64(time.Since(execStart))/float64(time.Millisecond), + ) + }) + } + + // completeCh is closed when OnExecuteComplete fires, meaning the final SSE + // event has been written and flushed. We only wait for this callback as a + // safety check and then return immediately to avoid fixed tail latency. + completeCh := make(chan struct{}) + var completeOnce sync.Once + signalComplete := func() { + completeOnce.Do(func() { + close(completeCh) + }) + } + hooks := c.setServerEventsHandler(ctx) + origComplete := hooks.OnExecuteComplete + hooks.OnExecuteComplete = func(executionTime time.Duration) { + origComplete(executionTime) + recordExecution("success") + signalComplete() + } + origError := hooks.OnExecuteError + hooks.OnExecuteError = func(err *execute.ErrorOutput) { + origError(err) + recordExecution("failure") + signalComplete() + } + runReq.Hooks = hooks + + // SSE headers are committed lazily on the first event write + // (see writeSingleEvent), so a synchronous error from + // RunInBashSession can still be surfaced as a structured JSON error. + err := codeRunner.RunInBashSession(ctx, runReq) + if err != nil { + recordExecution("failure") + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error running in session. %v", err), + ) + return + } + + waitForExecutionComplete(ctx, completeCh) +} + +// DeleteSession deletes a bash session (delete_session API). +func (c *CodeInterpretingController) DeleteSession() { + sessionID := c.ctx.Param("sessionId") + if sessionID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + err := codeRunner.DeleteBashSession(sessionID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("session %s not found", sessionID), + ) + return + } + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting session %s. %v", sessionID, err), + ) + return + } + + c.RespondSuccess(nil) +} + +// buildExecuteCodeRequest converts a RunCodeRequest to runtime format. +func (c *CodeInterpretingController) buildExecuteCodeRequest(request model.RunCodeRequest) *runtime.ExecuteCodeRequest { + req := &runtime.ExecuteCodeRequest{ + Language: runtime.Language(request.Context.Language), + Code: request.Code, + Context: request.Context.ID, + } + + if req.Language == "" { + req.Language = runtime.Command + } + + return req +} + +func waitForExecutionComplete(ctx context.Context, completeCh <-chan struct{}) { + timer := time.NewTimer(flag.ApiGracefulShutdownTimeout) + defer func() { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + }() + + select { + case <-completeCh: + case <-ctx.Done(): + case <-timer.C: + } +} + +func (c *CodeInterpretingController) interrupt() { + session := c.ctx.Query("id") + if session == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'id'", + ) + return + } + + err := codeRunner.Interrupt(session) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error interruptting code context. %v", err), + ) + return + } + + c.RespondSuccess(nil) +} diff --git a/components/execd/pkg/web/controller/codeinterpreting_test.go b/components/execd/pkg/web/controller/codeinterpreting_test.go new file mode 100644 index 0000000..7f67edc --- /dev/null +++ b/components/execd/pkg/web/controller/codeinterpreting_test.go @@ -0,0 +1,423 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/flag" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" + "github.com/stretchr/testify/require" +) + +type fakeCodeRunner struct { + execute func(request *runtime.ExecuteCodeRequest) error + runInBashSession func(_ context.Context, _ *runtime.ExecuteCodeRequest) error +} + +func (f *fakeCodeRunner) CreateContext(_ *runtime.CreateContextRequest) (string, error) { + return "", nil +} + +func (f *fakeCodeRunner) Execute(request *runtime.ExecuteCodeRequest) error { + if f.execute != nil { + return f.execute(request) + } + return nil +} + +func (f *fakeCodeRunner) GetContext(_ string) (runtime.CodeContext, error) { + return runtime.CodeContext{}, nil +} + +func (f *fakeCodeRunner) GetCommandStatus(_ string) (*runtime.CommandStatus, error) { + return nil, nil +} + +func (f *fakeCodeRunner) ListContext(_ string) ([]runtime.CodeContext, error) { + return nil, nil +} + +func (f *fakeCodeRunner) DeleteLanguageContext(_ runtime.Language) error { + return nil +} + +func (f *fakeCodeRunner) DeleteContext(_ string) error { + return nil +} + +func (f *fakeCodeRunner) CreateBashSession(_ *runtime.CreateContextRequest) (string, error) { + return "", nil +} + +func (f *fakeCodeRunner) RunInBashSession(ctx context.Context, req *runtime.ExecuteCodeRequest) error { + if f.runInBashSession != nil { + return f.runInBashSession(ctx, req) + } + return nil +} + +func (f *fakeCodeRunner) SeekBackgroundCommandOutput(_ string, _ int64) ([]byte, int64, error) { + return nil, 0, nil +} + +func (f *fakeCodeRunner) DeleteBashSession(_ string) error { + return nil +} + +func (f *fakeCodeRunner) Interrupt(_ string) error { + return nil +} + +func (f *fakeCodeRunner) CreatePTYSession(_ string, _ string, _ string) (runtime.PTYSession, error) { + return nil, nil +} +func (f *fakeCodeRunner) GetPTYSession(_ string) runtime.PTYSession { return nil } +func (f *fakeCodeRunner) DeletePTYSession(_ string) error { return nil } +func (f *fakeCodeRunner) GetPTYSessionStatus(_ string) (bool, int64, error) { return false, 0, nil } + +func TestBuildExecuteCodeRequestDefaultsToCommand(t *testing.T) { + ctrl := &CodeInterpretingController{} + req := model.RunCodeRequest{ + Code: "echo 1", + Context: model.CodeContext{ + ID: "session-1", + CodeContextRequest: model.CodeContextRequest{}, + }, + } + + execReq := ctrl.buildExecuteCodeRequest(req) + + require.Equal(t, runtime.Command, execReq.Language, "expected default language") + require.Equal(t, "session-1", execReq.Context) + require.Equal(t, "echo 1", execReq.Code) +} + +func TestBuildExecuteCodeRequestRespectsLanguage(t *testing.T) { + ctrl := &CodeInterpretingController{} + req := model.RunCodeRequest{ + Code: "print(1)", + Context: model.CodeContext{ + ID: "session-2", + CodeContextRequest: model.CodeContextRequest{ + Language: "python", + }, + }, + } + + execReq := ctrl.buildExecuteCodeRequest(req) + + require.Equal(t, runtime.Language("python"), execReq.Language) +} + +func TestGetContext_NotFoundReturns404(t *testing.T) { + ctx, w := newTestContext(http.MethodGet, "/code/contexts/missing", nil) + ctx.Params = append(ctx.Params, gin.Param{Key: "contextId", Value: "missing"}) + ctrl := NewCodeInterpretingController(ctx) + + previous := codeRunner + codeRunner = runtime.NewController("", "") + t.Cleanup(func() { codeRunner = previous }) + + ctrl.GetContext() + + require.Equal(t, http.StatusNotFound, w.Code) + + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeContextNotFound, resp.Code) + require.Equal(t, "context missing not found", resp.Message) +} + +func TestGetContext_MissingIDReturns400(t *testing.T) { + ctx, w := newTestContext(http.MethodGet, "/code/contexts/", nil) + ctrl := NewCodeInterpretingController(ctx) + + ctrl.GetContext() + + require.Equal(t, http.StatusBadRequest, w.Code) + + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeMissingQuery, resp.Code) + require.Equal(t, "missing path parameter 'contextId'", resp.Message) +} + +func TestRunCodeReturnsBeforeGracefulShutdownTimeoutAfterImmediateComplete(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + codeRunner = &fakeCodeRunner{ + execute: func(request *runtime.ExecuteCodeRequest) error { + request.Hooks.OnExecuteComplete(5 * time.Millisecond) + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 200 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + }) + + body := []byte(`{"code":"print(1)","context":{"id":"ctx-1","language":"python"}}`) + ctx, w := newTestContext(http.MethodPost, "/code/run", body) + ctrl := NewCodeInterpretingController(ctx) + + start := time.Now() + ctrl.RunCode() + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + require.Less(t, elapsed, flag.ApiGracefulShutdownTimeout/2) +} + +func TestRunInSessionReturnsBeforeGracefulShutdownTimeoutAfterImmediateComplete(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + codeRunner = &fakeCodeRunner{ + runInBashSession: func(_ context.Context, request *runtime.ExecuteCodeRequest) error { + request.Hooks.OnExecuteComplete(5 * time.Millisecond) + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 200 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + }) + + body := []byte(`{"command":"echo hi","timeout":0}`) + ctx, w := newTestContext(http.MethodPost, "/sessions/session-1/run", body) + ctx.Params = append(ctx.Params, gin.Param{Key: "sessionId", Value: "session-1"}) + ctrl := NewCodeInterpretingController(ctx) + + start := time.Now() + ctrl.RunInSession() + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + require.Less(t, elapsed, flag.ApiGracefulShutdownTimeout/2) +} + +func TestRunCodeReturnsBeforeGracefulShutdownTimeoutWhenRequestContextCanceled(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + reqCtx, cancelReq := context.WithCancel(context.Background()) + codeRunner = &fakeCodeRunner{ + execute: func(_ *runtime.ExecuteCodeRequest) error { + cancelReq() + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 200 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + cancelReq() + }) + + body := []byte(`{"code":"print(1)","context":{"id":"ctx-1","language":"python"}}`) + ctx, w := newTestContext(http.MethodPost, "/code/run", body) + ctx.Request = ctx.Request.WithContext(reqCtx) + ctrl := NewCodeInterpretingController(ctx) + + start := time.Now() + ctrl.RunCode() + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + require.Less(t, elapsed, flag.ApiGracefulShutdownTimeout/2) +} + +func TestRunInSessionReturnsBeforeGracefulShutdownTimeoutWhenRequestContextCanceled(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + reqCtx, cancelReq := context.WithCancel(context.Background()) + codeRunner = &fakeCodeRunner{ + runInBashSession: func(_ context.Context, _ *runtime.ExecuteCodeRequest) error { + cancelReq() + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 200 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + cancelReq() + }) + + body := []byte(`{"command":"echo hi","timeout":0}`) + ctx, w := newTestContext(http.MethodPost, "/sessions/session-1/run", body) + ctx.Request = ctx.Request.WithContext(reqCtx) + ctx.Params = append(ctx.Params, gin.Param{Key: "sessionId", Value: "session-1"}) + ctrl := NewCodeInterpretingController(ctx) + + start := time.Now() + ctrl.RunInSession() + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + require.Less(t, elapsed, flag.ApiGracefulShutdownTimeout/2) +} + +func TestRunCodeReturnsBeforeGracefulShutdownTimeoutAfterImmediateError(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + codeRunner = &fakeCodeRunner{ + execute: func(request *runtime.ExecuteCodeRequest) error { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "ExecError", EValue: "boom"}) + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 200 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + }) + + body := []byte(`{"code":"print(1)","context":{"id":"ctx-1","language":"python"}}`) + ctx, w := newTestContext(http.MethodPost, "/code/run", body) + ctrl := NewCodeInterpretingController(ctx) + + start := time.Now() + ctrl.RunCode() + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + require.Less(t, elapsed, flag.ApiGracefulShutdownTimeout/2) +} + +func TestRunInSessionReturnsBeforeGracefulShutdownTimeoutAfterImmediateError(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + codeRunner = &fakeCodeRunner{ + runInBashSession: func(_ context.Context, request *runtime.ExecuteCodeRequest) error { + request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "ExecError", EValue: "boom"}) + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 200 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + }) + + body := []byte(`{"command":"echo hi","timeout":0}`) + ctx, w := newTestContext(http.MethodPost, "/sessions/session-1/run", body) + ctx.Params = append(ctx.Params, gin.Param{Key: "sessionId", Value: "session-1"}) + ctrl := NewCodeInterpretingController(ctx) + + start := time.Now() + ctrl.RunInSession() + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + require.Less(t, elapsed, flag.ApiGracefulShutdownTimeout/2) +} + +// TestRunCodeSyncErrorEmitsJSONNotSSE guards against regression of the bug +// where Execute returning a synchronous error after setupSSEResponse caused +// the client to receive a text/event-stream response with a JSON body, which +// SDKs parsed as zero events ("empty sse stream"). Headers must stay +// uncommitted until the first event so RespondError can produce a proper +// application/json error response. +func TestRunCodeSyncErrorEmitsJSONNotSSE(t *testing.T) { + previousRunner := codeRunner + codeRunner = &fakeCodeRunner{ + execute: func(_ *runtime.ExecuteCodeRequest) error { + return errors.New("synchronous runtime failure") + }, + } + t.Cleanup(func() { codeRunner = previousRunner }) + + body := []byte(`{"code":"print(1)","context":{"id":"ctx-1","language":"python"}}`) + ctx, w := newTestContext(http.MethodPost, "/code/run", body) + ctrl := NewCodeInterpretingController(ctx) + + ctrl.RunCode() + + require.Equal(t, http.StatusInternalServerError, w.Code) + contentType := w.Header().Get("Content-Type") + require.Contains(t, contentType, "application/json", "should not commit text/event-stream when no event fires") + + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeRuntimeError, resp.Code) + require.Contains(t, resp.Message, "synchronous runtime failure") +} + +// TestRunInSessionSyncErrorEmitsJSONNotSSE — see TestRunCodeSyncErrorEmitsJSONNotSSE. +func TestRunInSessionSyncErrorEmitsJSONNotSSE(t *testing.T) { + previousRunner := codeRunner + codeRunner = &fakeCodeRunner{ + runInBashSession: func(_ context.Context, _ *runtime.ExecuteCodeRequest) error { + return errors.New("synchronous session failure") + }, + } + t.Cleanup(func() { codeRunner = previousRunner }) + + body := []byte(`{"command":"echo hi","timeout":0}`) + ctx, w := newTestContext(http.MethodPost, "/sessions/session-1/run", body) + ctx.Params = append(ctx.Params, gin.Param{Key: "sessionId", Value: "session-1"}) + ctrl := NewCodeInterpretingController(ctx) + + ctrl.RunInSession() + + require.Equal(t, http.StatusInternalServerError, w.Code) + contentType := w.Header().Get("Content-Type") + require.Contains(t, contentType, "application/json", "should not commit text/event-stream when no event fires") + + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeRuntimeError, resp.Code) + require.Contains(t, resp.Message, "synchronous session failure") +} + +// TestRunCodeSuccessStillEmitsSSE confirms the lazy header path still produces +// a text/event-stream response when at least one event fires. +func TestRunCodeSuccessStillEmitsSSE(t *testing.T) { + previousRunner := codeRunner + previousTimeout := flag.ApiGracefulShutdownTimeout + codeRunner = &fakeCodeRunner{ + execute: func(request *runtime.ExecuteCodeRequest) error { + request.Hooks.OnExecuteInit("session-1") + request.Hooks.OnExecuteComplete(time.Millisecond) + return nil + }, + } + flag.ApiGracefulShutdownTimeout = 50 * time.Millisecond + t.Cleanup(func() { + codeRunner = previousRunner + flag.ApiGracefulShutdownTimeout = previousTimeout + }) + + body := []byte(`{"code":"print(1)","context":{"id":"ctx-1","language":"python"}}`) + ctx, w := newTestContext(http.MethodPost, "/code/run", body) + ctrl := NewCodeInterpretingController(ctx) + + ctrl.RunCode() + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Header().Get("Content-Type"), "text/event-stream") + require.NotEmpty(t, w.Body.Bytes(), "successful run should write SSE events") +} diff --git a/components/execd/pkg/web/controller/command.go b/components/execd/pkg/web/controller/command.go new file mode 100644 index 0000000..9c21ec6 --- /dev/null +++ b/components/execd/pkg/web/controller/command.go @@ -0,0 +1,179 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/flag" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/telemetry" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// RunCommand executes a shell command and streams the output via SSE. +func (c *CodeInterpretingController) RunCommand() { + var request model.RunCommandRequest + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + err := request.Validate() + if err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid request, validation error %v", err), + ) + return + } + + ctx, cancel := context.WithCancel(c.ctx.Request.Context()) + defer cancel() + execStart := time.Now() + var recordOnce sync.Once + recordExecution := func(result string) { + recordOnce.Do(func() { + telemetry.RecordExecutionDuration( + ctx, + "run_command", + result, + float64(time.Since(execStart))/float64(time.Millisecond), + ) + }) + } + + runCodeRequest := c.buildExecuteCommandRequest(request) + eventsHandler := c.setServerEventsHandler(ctx) + origComplete := eventsHandler.OnExecuteComplete + eventsHandler.OnExecuteComplete = func(executionTime time.Duration) { + origComplete(executionTime) + recordExecution("success") + } + origError := eventsHandler.OnExecuteError + eventsHandler.OnExecuteError = func(err *execute.ErrorOutput) { + origError(err) + recordExecution("failure") + } + runCodeRequest.Hooks = eventsHandler + + // SSE headers are committed lazily on the first event write + // (see writeSingleEvent), so a synchronous error from Execute below can + // still be surfaced as a structured JSON error response. + err = codeRunner.Execute(runCodeRequest) + if err != nil { + recordExecution("failure") + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error running commands %v", err), + ) + return + } + + time.Sleep(flag.ApiGracefulShutdownTimeout) +} + +// InterruptCommand stops a running shell command session. +func (c *CodeInterpretingController) InterruptCommand() { + c.interrupt() +} + +// GetCommandStatus returns command status by id. +func (c *CodeInterpretingController) GetCommandStatus() { + commandID := c.ctx.Param("id") + if commandID == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "missing command execution id") + return + } + + status, err := codeRunner.GetCommandStatus(commandID) + if err != nil { + c.RespondError(http.StatusNotFound, model.ErrorCodeInvalidRequest, err.Error()) + return + } + + resp := model.CommandStatusResponse{ + ID: status.Session, + Running: status.Running, + ExitCode: status.ExitCode, + Error: status.Error, + Content: status.Content, + } + if !status.StartedAt.IsZero() { + resp.StartedAt = status.StartedAt + } + if status.FinishedAt != nil { + resp.FinishedAt = status.FinishedAt + } + + c.RespondSuccess(resp) +} + +// GetBackgroundCommandOutput returns accumulated stdout/stderr for a command session as plain text. +func (c *CodeInterpretingController) GetBackgroundCommandOutput() { + id := c.ctx.Param("id") + if id == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing command execution id") + return + } + + cursor := c.QueryInt64(c.ctx.Query("cursor"), 0) + output, lastCursor, err := codeRunner.SeekBackgroundCommandOutput(id, cursor) + if err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error()) + return + } + + c.ctx.Header("EXECD-COMMANDS-TAIL-CURSOR", strconv.FormatInt(lastCursor, 10)) + c.ctx.Header("Content-Type", "text/plain; charset=utf-8") + c.ctx.String(http.StatusOK, "%s", output) +} + +func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.RunCommandRequest) *runtime.ExecuteCodeRequest { + timeout := time.Duration(request.TimeoutMs) * time.Millisecond + if request.Background { + return &runtime.ExecuteCodeRequest{ + Language: runtime.BackgroundCommand, + Code: request.Command, + Cwd: request.Cwd, + Timeout: timeout, + Gid: request.Gid, + Uid: request.Uid, + Envs: request.Envs, + } + } else { + return &runtime.ExecuteCodeRequest{ + Language: runtime.Command, + Code: request.Command, + Cwd: request.Cwd, + Timeout: timeout, + Gid: request.Gid, + Uid: request.Uid, + Envs: request.Envs, + } + } +} diff --git a/components/execd/pkg/web/controller/command_test.go b/components/execd/pkg/web/controller/command_test.go new file mode 100644 index 0000000..7473596 --- /dev/null +++ b/components/execd/pkg/web/controller/command_test.go @@ -0,0 +1,90 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" + "github.com/stretchr/testify/require" +) + +func TestBuildExecuteCommandRequestForwardsEnvs(t *testing.T) { + ctrl := &CodeInterpretingController{} + envs := map[string]string{"FOO": "bar", "BAZ": "qux"} + req := model.RunCommandRequest{ + Command: "echo hi", + Cwd: "/tmp", + Envs: envs, + } + + execReq := ctrl.buildExecuteCommandRequest(req) + + require.Equal(t, runtime.Command, execReq.Language) + require.True(t, reflect.DeepEqual(execReq.Envs, envs), "expected envs to be forwarded") + require.Equal(t, "/tmp", execReq.Cwd) +} + +func TestBuildExecuteCommandRequestForwardsEnvsBackground(t *testing.T) { + ctrl := &CodeInterpretingController{} + envs := map[string]string{"FOO": "bar"} + req := model.RunCommandRequest{ + Command: "echo hi", + Background: true, + Envs: envs, + } + + execReq := ctrl.buildExecuteCommandRequest(req) + + require.Equal(t, runtime.BackgroundCommand, execReq.Language) + require.True(t, reflect.DeepEqual(execReq.Envs, envs), "expected envs to be forwarded") +} + +func setupCommandController(method, path string) (*CodeInterpretingController, *httptest.ResponseRecorder) { + ctx, w := newTestContext(method, path, nil) + ctrl := NewCodeInterpretingController(ctx) + return ctrl, w +} + +func TestGetCommandStatus_MissingID(t *testing.T) { + ctrl, w := setupCommandController(http.MethodGet, "/command/status/") + + ctrl.GetCommandStatus() + + require.Equal(t, http.StatusBadRequest, w.Code) + + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeInvalidRequest, resp.Code) + require.Equal(t, "missing command execution id", resp.Message) +} + +func TestGetBackgroundCommandOutput_MissingID(t *testing.T) { + ctrl, w := setupCommandController(http.MethodGet, "/command/logs/") + + ctrl.GetBackgroundCommandOutput() + + require.Equal(t, http.StatusBadRequest, w.Code) + + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeMissingQuery, resp.Code) + require.Equal(t, "missing command execution id", resp.Message) +} diff --git a/components/execd/pkg/web/controller/filesystem.go b/components/execd/pkg/web/controller/filesystem.go new file mode 100644 index 0000000..17767e0 --- /dev/null +++ b/components/execd/pkg/web/controller/filesystem.go @@ -0,0 +1,494 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package controller + +import ( + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/util/glob" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// TODO: migrate FilesystemController to use vfs.FS interface, +// unifying the filesystem backend with IsolatedSessionController's file handlers. + +// FilesystemController handles file system operations +type FilesystemController struct { + *basicController +} + +func NewFilesystemController(ctx *gin.Context) *FilesystemController { + return &FilesystemController{basicController: newBasicController(ctx)} +} + +func (c *FilesystemController) handleFileError(err error) { + if errors.Is(err, fs.ErrNotExist) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeFileNotFound, + fmt.Sprintf("file not found. %v", err), + ) + } else { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error accessing file: %v", err), + ) + } +} + +// GetFilesInfo retrieves metadata for specified file paths +func (c *FilesystemController) GetFilesInfo() { + rec := beginFilesystemMetric("info") + defer rec.Finish(c.basicController) + + paths := c.ctx.QueryArray("path") + if len(paths) == 0 { + rec.MarkSuccess() + c.RespondSuccess(make(map[string]model.FileInfo)) + return + } + + resp := make(map[string]model.FileInfo) + for _, filePath := range paths { + fileInfo, err := GetFileInfo(filePath) + if err != nil { + c.handleFileError(err) + return + } + resp[filePath] = fileInfo + } + + rec.MarkSuccess() + c.RespondSuccess(resp) +} + +// RemoveFiles deletes specified files +func (c *FilesystemController) RemoveFiles() { + rec := beginFilesystemMetric("delete") + defer rec.Finish(c.basicController) + + paths := c.ctx.QueryArray("path") + for _, filePath := range paths { + if err := DeleteFile(filePath); err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error removing file %s. %v", filePath, err), + ) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// ChmodFiles changes file permissions for specified files +func (c *FilesystemController) ChmodFiles() { + rec := beginFilesystemMetric("chmod") + defer rec.Finish(c.basicController) + + var request map[string]model.Permission + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + for file, item := range request { + err := ChmodFile(file, item) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error changing permissions for %s. %v", file, err), + ) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// RenameFiles renames or moves files to new paths +func (c *FilesystemController) RenameFiles() { + rec := beginFilesystemMetric("rename") + defer rec.Finish(c.basicController) + + var request []model.RenameFileItem + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + for _, renameItem := range request { + if err := RenameFile(renameItem); err != nil { + c.handleFileError(err) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// MakeDirs creates directories with specified permissions +func (c *FilesystemController) MakeDirs() { + rec := beginFilesystemMetric("mkdir") + defer rec.Finish(c.basicController) + + var request map[string]model.Permission + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + for dir, perm := range request { + if err := MakeDir(dir, perm); err != nil { + c.handleFileError(err) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// RemoveDirs recursively removes directories +func (c *FilesystemController) RemoveDirs() { + rec := beginFilesystemMetric("rmdir") + defer rec.Finish(c.basicController) + + paths := c.ctx.QueryArray("path") + for _, dir := range paths { + resolvedDir, err := pathutil.ExpandPath(dir) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error resolving directory %s. %v", dir, err), + ) + return + } + if err := os.RemoveAll(resolvedDir); err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error removing directory %s. %v", dir, err), + ) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// ListDirectory lists directory contents with optional depth control +func (c *FilesystemController) ListDirectory() { + rec := beginFilesystemMetric("listdir") + defer rec.Finish(c.basicController) + + path := c.ctx.Query("path") + if path == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'path'", + ) + return + } + + depth := 1 + if rawDepth := c.ctx.Query("depth"); rawDepth != "" { + parsedDepth, err := strconv.Atoi(rawDepth) + if err != nil || parsedDepth < 0 { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'depth': %s", rawDepth), + ) + return + } + depth = parsedDepth + } + + path, err := pathutil.ExpandAbsPath(path) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error converting path %s to absolute. %v", path, err), + ) + return + } + + // Use Lstat so a symlink passed as the root is detected and rejected + // rather than silently followed: /directories/list never traverses + // symlinks (see the public spec), so listing through a symlink-as-root + // would expose a different subtree than the caller asked for. + info, err := os.Lstat(path) + if err != nil { + c.handleFileError(err) + return + } + if info.Mode()&os.ModeSymlink != 0 { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("path is a symbolic link, refusing to traverse: %s", path), + ) + return + } + if !info.IsDir() { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("path is not a directory: %s", path), + ) + return + } + + entries, err := listDirectoryEntries(path, depth) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error listing directory %s. %v", path, err), + ) + return + } + + rec.MarkSuccess() + c.RespondSuccess(entries) +} + +func listDirectoryEntries(root string, maxDepth int) ([]model.FileInfo, error) { + entries := make([]model.FileInfo, 0, 16) + if maxDepth == 0 { + return entries, nil + } + + var walk func(string, int) error + walk = func(dir string, currentDepth int) error { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range dirEntries { + entryPath := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + return err + } + + entryInfo, err := buildFileInfo(entryPath, info) + if err != nil { + return err + } + entries = append(entries, entryInfo) + + if entry.IsDir() && currentDepth+1 < maxDepth { + if err := walk(entryPath, currentDepth+1); err != nil { + return err + } + } + } + return nil + } + + return entries, walk(root, 0) +} + +// SearchFiles searches for files matching a pattern in a directory +func (c *FilesystemController) SearchFiles() { + rec := beginFilesystemMetric("search") + defer rec.Finish(c.basicController) + + path := c.ctx.Query("path") + if path == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'path'", + ) + return + } + + path, err := pathutil.ExpandAbsPath(path) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error converting path %s to absolute. %v", path, err), + ) + return + } + + _, err = os.Stat(path) + if err != nil { + c.handleFileError(err) + return + } + + pattern := c.ctx.Query("pattern") + if pattern == "" { + pattern = "**" + } + + files := make([]model.FileInfo, 0, 16) + err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("error accessing path %s: %w", filePath, err) + } + if info.IsDir() { + return nil + } + + match, err := glob.PathMatch(pattern, info.Name()) + if err != nil { + return fmt.Errorf("invalid pattern %s: %w", pattern, err) + } + + if match { + fileInfo, err := buildFileInfo(filePath, info) + if err != nil { + return err + } + files = append(files, fileInfo) + } + + return nil + }) + + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error searching files. %v", err), + ) + return + } + + rec.MarkSuccess() + c.RespondSuccess(files) +} + +// ReplaceContent replaces text content in specified files +func (c *FilesystemController) ReplaceContent() { + rec := beginFilesystemMetric("replace") + defer rec.Finish(c.basicController) + + verbose := c.ctx.Query("verbose") == "true" + + var request map[string]model.ReplaceFileContentItem + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + var results map[string]model.ReplaceFileContentResult + if verbose { + results = make(map[string]model.ReplaceFileContentResult) + } + + for file, item := range request { + origPath := file + file, err := pathutil.ExpandAbsPath(file) + if err != nil { + c.handleFileError(err) + return + } + + if _, err = os.Stat(file); err != nil { + c.handleFileError(err) + return + } + + content, err := os.ReadFile(file) + if err != nil { + c.handleFileError(err) + return + } + + fileInfo, err := os.Stat(file) + if err != nil { + c.handleFileError(err) + return + } + mode := fileInfo.Mode() + + if item.Old == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "old content must not be empty") + return + } + + contentStr := string(content) + newContent := strings.ReplaceAll(contentStr, item.Old, item.New) + + err = os.WriteFile(file, []byte(newContent), mode) + if err != nil { + c.handleFileError(err) + return + } + + if verbose { + results[origPath] = model.ReplaceFileContentResult{ + ReplacedCount: strings.Count(contentStr, item.Old), + } + } + } + + rec.MarkSuccess() + if verbose { + c.RespondSuccess(results) + } else { + c.RespondSuccess(nil) + } +} diff --git a/components/execd/pkg/web/controller/filesystem_download.go b/components/execd/pkg/web/controller/filesystem_download.go new file mode 100644 index 0000000..25635ad --- /dev/null +++ b/components/execd/pkg/web/controller/filesystem_download.go @@ -0,0 +1,203 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// DownloadFile serves a file for download with support for range requests +// and line-based reading via offset/limit query parameters. +func (c *FilesystemController) DownloadFile() { + rec := beginFilesystemMetric("download") + defer rec.Finish(c.basicController) + + filePath := c.ctx.Query("path") + if filePath == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'path'", + ) + return + } + resolvedFilePath, err := pathutil.ExpandPath(filePath) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error resolving file path: %s. %v", filePath, err), + ) + return + } + + rawOffset := c.ctx.Query("offset") + rawLimit := c.ctx.Query("limit") + hasLineParams := rawOffset != "" || rawLimit != "" + rangeHeader := c.ctx.GetHeader("Range") + + if hasLineParams && rangeHeader != "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + "line-based reading (offset/limit) and byte range (Range header) are mutually exclusive", + ) + return + } + + file, err := os.Open(resolvedFilePath) + if err != nil { + c.handleFileError(err) + return + } + defer file.Close() + + if hasLineParams { + c.serveLineRange(file, rawOffset, rawLimit) + rec.MarkSuccess() + return + } + + fileInfo, err := file.Stat() + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error getting file stat info: %s. %v", resolvedFilePath, err), + ) + return + } + + c.ctx.Header("Content-Type", "application/octet-stream") + c.ctx.Header("Content-Disposition", formatContentDisposition(filepath.Base(resolvedFilePath))) + c.ctx.Header("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) + + if rangeHeader != "" { + ranges, err := ParseRange(rangeHeader, fileInfo.Size()) + if err != nil { + c.RespondError( + http.StatusRequestedRangeNotSatisfiable, + model.ErrorCodeUnknown, + ) + return + } + if len(ranges) > 0 { + r := ranges[0] + c.ctx.Status(http.StatusPartialContent) + c.ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, fileInfo.Size())) + c.ctx.Header("Content-Length", strconv.FormatInt(r.length, 10)) + + _, _ = file.Seek(r.start, io.SeekStart) + _, _ = io.CopyN(c.ctx.Writer, file, r.length) + rec.MarkSuccess() + return + } + } + + rec.MarkSuccess() + http.ServeContent(c.ctx.Writer, c.ctx.Request, filepath.Base(resolvedFilePath), fileInfo.ModTime(), file) +} + +// serveLineRange reads lines from file starting at a 1-based offset and +// returns up to limit lines as text/plain. +func (c *FilesystemController) serveLineRange(file *os.File, rawOffset, rawLimit string) { + offset := int64(1) + if rawOffset != "" { + parsed, err := strconv.ParseInt(rawOffset, 10, 64) + if err != nil || parsed < 1 { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'offset': %s", rawOffset), + ) + return + } + offset = parsed + } + + limit := int64(-1) + if rawLimit != "" { + parsed, err := strconv.ParseInt(rawLimit, 10, 64) + if err != nil || parsed < 1 { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'limit': %s", rawLimit), + ) + return + } + limit = parsed + } + + c.ctx.Header("Content-Type", "text/plain; charset=utf-8") + c.ctx.Status(http.StatusOK) + + reader := bufio.NewReader(file) + var lineNum int64 + var written int64 + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + line = bytes.TrimRight(line, "\r\n") + lineNum++ + if lineNum >= offset { + if written > 0 { + _, _ = c.ctx.Writer.Write([]byte("\n")) + } + _, _ = c.ctx.Writer.Write(line) + written++ + if limit >= 0 && written >= limit { + break + } + } + } + if err != nil { + break + } + } +} + +// formatContentDisposition formats the Content-Disposition header value with proper +// encoding for non-ASCII filenames according to RFC 6266 and RFC 5987. +func formatContentDisposition(filename string) string { + // Check if filename contains non-ASCII characters + needsEncoding := false + for _, r := range filename { + if r > 127 { + needsEncoding = true + break + } + } + + if !needsEncoding { + return "attachment; filename=\"" + filename + "\"" + } + + // Use RFC 5987 encoding for non-ASCII filenames + // Format: attachment; filename="fallback"; filename*=UTF-8''encoded_name + encodedFilename := url.PathEscape(filename) + return "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename +} diff --git a/components/execd/pkg/web/controller/filesystem_test.go b/components/execd/pkg/web/controller/filesystem_test.go new file mode 100644 index 0000000..f19a309 --- /dev/null +++ b/components/execd/pkg/web/controller/filesystem_test.go @@ -0,0 +1,551 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/alibaba/opensandbox/execd/pkg/web/model" + "github.com/stretchr/testify/require" +) + +func newFilesystemController(t *testing.T, method, rawURL string, body []byte) (*FilesystemController, *httptest.ResponseRecorder) { + t.Helper() + ctx, rec := newTestContext(method, rawURL, body) + ctrl := NewFilesystemController(ctx) + return ctrl, rec +} + +func TestFilesystemControllerGetFilesInfo(t *testing.T) { + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "foo.txt") + require.NoError(t, os.WriteFile(target, []byte("demo"), 0o644)) + + query := fmt.Sprintf("/files/info?path=%s", url.QueryEscape(target)) + ctrl, rec := newFilesystemController(t, http.MethodGet, query, nil) + + ctrl.GetFilesInfo() + + require.Equal(t, http.StatusOK, rec.Code) + var resp map[string]model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + info, ok := resp[target] + require.True(t, ok, "response missing entry for %s", target) + require.NotEmpty(t, info.Path) + require.Equal(t, "file", info.Type) + require.NotZero(t, info.Size) +} + +func TestFilesystemControllerGetFilesInfoReportsSymlink(t *testing.T) { + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "target.txt") + linkPath := filepath.Join(tmpDir, "link.txt") + require.NoError(t, os.WriteFile(target, []byte("demo"), 0o644)) + if err := os.Symlink(target, linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + query := fmt.Sprintf("/files/info?path=%s", url.QueryEscape(linkPath)) + ctrl, rec := newFilesystemController(t, http.MethodGet, query, nil) + + ctrl.GetFilesInfo() + + require.Equal(t, http.StatusOK, rec.Code) + var resp map[string]model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + info, ok := resp[linkPath] + require.True(t, ok, "response missing entry for %s", linkPath) + require.Equal(t, "symlink", info.Type) +} + +func TestFilesystemControllerGetFilesInfoReturnsNotFoundForMissingPath(t *testing.T) { + missing := filepath.Join(t.TempDir(), "definitely-does-not-exist.txt") + query := fmt.Sprintf("/files/info?path=%s", url.QueryEscape(missing)) + ctrl, rec := newFilesystemController(t, http.MethodGet, query, nil) + + ctrl.GetFilesInfo() + + require.Equal(t, http.StatusNotFound, rec.Code) + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeFileNotFound, resp.Code) +} + +func TestFilesystemControllerRenameFilesReturnsNotFoundForMissingSource(t *testing.T) { + tmpDir := t.TempDir() + missingSrc := filepath.Join(tmpDir, "missing.txt") + dst := filepath.Join(tmpDir, "dest.txt") + payload, err := json.Marshal([]model.RenameFileItem{{Src: missingSrc, Dest: dst}}) + require.NoError(t, err) + ctrl, rec := newFilesystemController(t, http.MethodPost, "/files/mv", payload) + + ctrl.RenameFiles() + + require.Equal(t, http.StatusNotFound, rec.Code) + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeFileNotFound, resp.Code) +} + +func TestFilesystemControllerSearchFiles(t *testing.T) { + tmpDir := t.TempDir() + a := filepath.Join(tmpDir, "alpha.txt") + b := filepath.Join(tmpDir, "beta.log") + require.NoError(t, os.WriteFile(a, []byte("alpha"), 0o644)) + require.NoError(t, os.WriteFile(b, []byte("beta"), 0o644)) + + rawURL := fmt.Sprintf("/files/search?path=%s&pattern=%s", url.QueryEscape(tmpDir), url.QueryEscape("*.txt")) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.SearchFiles() + + require.Equal(t, http.StatusOK, rec.Code) + var files []model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &files)) + require.Len(t, files, 1) + require.Equal(t, a, files[0].Path) + require.Equal(t, "file", files[0].Type) +} + +func TestFilesystemControllerListDirectoryDefaultDepth(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "alpha.txt") + dirPath := filepath.Join(tmpDir, "nested") + deepFile := filepath.Join(dirPath, "deep.txt") + require.NoError(t, os.MkdirAll(dirPath, 0o755)) + require.NoError(t, os.WriteFile(filePath, []byte("alpha"), 0o644)) + require.NoError(t, os.WriteFile(deepFile, []byte("deep"), 0o644)) + + rawURL := fmt.Sprintf("/directories/list?path=%s", url.QueryEscape(tmpDir)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.ListDirectory() + + require.Equal(t, http.StatusOK, rec.Code) + var entries []model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &entries)) + require.Len(t, entries, 2) + + byPath := map[string]model.FileInfo{} + for _, entry := range entries { + byPath[entry.Path] = entry + } + require.Equal(t, "file", byPath[filePath].Type) + require.Equal(t, "directory", byPath[dirPath].Type) + require.NotContains(t, byPath, deepFile) +} + +func TestFilesystemControllerListDirectoryDepth(t *testing.T) { + tmpDir := t.TempDir() + nested := filepath.Join(tmpDir, "nested") + deeper := filepath.Join(nested, "deeper") + deepFile := filepath.Join(nested, "deep.txt") + tooDeep := filepath.Join(deeper, "too-deep.txt") + require.NoError(t, os.MkdirAll(deeper, 0o755)) + require.NoError(t, os.WriteFile(deepFile, []byte("deep"), 0o644)) + require.NoError(t, os.WriteFile(tooDeep, []byte("too deep"), 0o644)) + + rawURL := fmt.Sprintf("/directories/list?path=%s&depth=2", url.QueryEscape(tmpDir)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.ListDirectory() + + require.Equal(t, http.StatusOK, rec.Code) + var entries []model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &entries)) + + byPath := map[string]model.FileInfo{} + for _, entry := range entries { + byPath[entry.Path] = entry + } + require.Equal(t, "directory", byPath[nested].Type) + require.Equal(t, "directory", byPath[deeper].Type) + require.Equal(t, "file", byPath[deepFile].Type) + require.NotContains(t, byPath, tooDeep) +} + +func TestFilesystemControllerListDirectoryDepthZero(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "alpha.txt"), []byte("alpha"), 0o644)) + + rawURL := fmt.Sprintf("/directories/list?path=%s&depth=0", url.QueryEscape(tmpDir)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.ListDirectory() + + require.Equal(t, http.StatusOK, rec.Code) + var entries []model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &entries)) + require.Empty(t, entries) +} + +func TestFilesystemControllerListDirectoryReportsSymlinkWithoutRecursing(t *testing.T) { + tmpDir := t.TempDir() + targetDir := filepath.Join(tmpDir, "target") + linkPath := filepath.Join(tmpDir, "link") + targetFile := filepath.Join(targetDir, "target.txt") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + require.NoError(t, os.WriteFile(targetFile, []byte("target"), 0o644)) + if err := os.Symlink(targetDir, linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + rawURL := fmt.Sprintf("/directories/list?path=%s&depth=2", url.QueryEscape(tmpDir)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.ListDirectory() + + require.Equal(t, http.StatusOK, rec.Code) + var entries []model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &entries)) + + byPath := map[string]model.FileInfo{} + for _, entry := range entries { + byPath[entry.Path] = entry + } + require.Equal(t, "symlink", byPath[linkPath].Type) + require.NotContains(t, byPath, filepath.Join(linkPath, "target.txt")) + require.Contains(t, byPath, targetFile) +} + +func TestFilesystemControllerListDirectoryReturnsLexicalOrder(t *testing.T) { + tmpDir := t.TempDir() + // Create entries in non-lexical creation order to make sure the response + // reflects the contracted lexical-by-name ordering rather than insertion + // order or os-specific listing order. + for _, name := range []string{"charlie.txt", "alpha", "bravo.txt"} { + full := filepath.Join(tmpDir, name) + if name == "alpha" { + require.NoError(t, os.MkdirAll(full, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(full, "y.txt"), []byte("y"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(full, "x.txt"), []byte("x"), 0o644)) + continue + } + require.NoError(t, os.WriteFile(full, []byte(name), 0o644)) + } + + rawURL := fmt.Sprintf("/directories/list?path=%s&depth=2", url.QueryEscape(tmpDir)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.ListDirectory() + + require.Equal(t, http.StatusOK, rec.Code) + var entries []model.FileInfo + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &entries)) + + paths := make([]string, 0, len(entries)) + for _, e := range entries { + paths = append(paths, e.Path) + } + require.Equal(t, []string{ + filepath.Join(tmpDir, "alpha"), + filepath.Join(tmpDir, "alpha", "x.txt"), + filepath.Join(tmpDir, "alpha", "y.txt"), + filepath.Join(tmpDir, "bravo.txt"), + filepath.Join(tmpDir, "charlie.txt"), + }, paths) +} + +func TestFilesystemControllerListDirectoryRejectsSymlinkRoot(t *testing.T) { + tmpDir := t.TempDir() + targetDir := filepath.Join(tmpDir, "real") + linkPath := filepath.Join(tmpDir, "link-to-real") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "leak.txt"), []byte("leak"), 0o644)) + if err := os.Symlink(targetDir, linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + rawURL := fmt.Sprintf("/directories/list?path=%s", url.QueryEscape(linkPath)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.ListDirectory() + + require.Equal(t, http.StatusBadRequest, rec.Code, "symlink as root should be rejected per spec") + // Make sure the response body does not leak the target directory contents. + require.NotContains(t, rec.Body.String(), "leak.txt") +} + +func TestFilesystemControllerListDirectoryRejectsInvalidRequests(t *testing.T) { + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("file"), 0o644)) + + tests := []struct { + name string + rawURL string + want int + }{ + {name: "missing path", rawURL: "/directories/list", want: http.StatusBadRequest}, + {name: "missing directory", rawURL: "/directories/list?path=/not/exists", want: http.StatusNotFound}, + {name: "file path", rawURL: fmt.Sprintf("/directories/list?path=%s", url.QueryEscape(filePath)), want: http.StatusBadRequest}, + {name: "invalid depth", rawURL: fmt.Sprintf("/directories/list?path=%s&depth=abc", url.QueryEscape(tmpDir)), want: http.StatusBadRequest}, + {name: "negative depth", rawURL: fmt.Sprintf("/directories/list?path=%s&depth=-1", url.QueryEscape(tmpDir)), want: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl, rec := newFilesystemController(t, http.MethodGet, tt.rawURL, nil) + ctrl.ListDirectory() + require.Equal(t, tt.want, rec.Code) + }) + } +} + +func TestFilesystemControllerReplaceContent(t *testing.T) { + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "content.txt") + require.NoError(t, os.WriteFile(target, []byte("hello world"), 0o644)) + + body, err := json.Marshal(map[string]model.ReplaceFileContentItem{ + target: { + Old: "world", + New: "universe", + }, + }) + require.NoError(t, err) + + ctrl, rec := newFilesystemController(t, http.MethodPost, "/files/replace", body) + + ctrl.ReplaceContent() + + require.Equal(t, http.StatusOK, rec.Code) + data, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, "hello universe", string(data)) +} + +func TestFilesystemControllerReplaceContentSupportsHomePath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + target := filepath.Join(home, "content.txt") + require.NoError(t, os.WriteFile(target, []byte("hello world"), 0o644)) + + body, err := json.Marshal(map[string]model.ReplaceFileContentItem{ + "~/content.txt": { + Old: "world", + New: "home", + }, + }) + require.NoError(t, err) + + ctrl, rec := newFilesystemController(t, http.MethodPost, "/files/replace", body) + ctrl.ReplaceContent() + + require.Equal(t, http.StatusOK, rec.Code) + data, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, "hello home", string(data)) +} + +func TestFilesystemControllerSearchFilesHandlesAbsentDir(t *testing.T) { + rawURL := "/files/search?path=/not/exists" + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.SearchFiles() + + require.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestReplaceContentFailsUnknownFile(t *testing.T) { + payload, _ := json.Marshal(map[string]model.ReplaceFileContentItem{ + filepath.Join(t.TempDir(), "missing.txt"): { + Old: "old", + New: "new", + }, + }) + ctrl, rec := newFilesystemController(t, http.MethodPost, "/files/replace", payload) + + ctrl.ReplaceContent() + + require.Equal(t, http.StatusNotFound, rec.Code) + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeFileNotFound, resp.Code) +} + +func TestFormatContentDisposition(t *testing.T) { + tests := []struct { + name string + filename string + want string + }{ + { + name: "ASCII filename", + filename: "test.txt", + want: "attachment; filename=\"test.txt\"", + }, + { + name: "Chinese filename", + filename: "测试文件.txt", + want: "attachment; filename=\"%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt\"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt", + }, + { + name: "Japanese filename", + filename: "テスト.txt", + want: "attachment; filename=\"%E3%83%86%E3%82%B9%E3%83%88.txt\"; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt", + }, + { + name: "Special characters in filename", + filename: "file with spaces.txt", + want: "attachment; filename=\"file with spaces.txt\"", + }, + { + name: "Mixed ASCII and non-ASCII", + filename: "report-报告.pdf", + want: "attachment; filename=\"report-%E6%8A%A5%E5%91%8A.pdf\"; filename*=UTF-8''report-%E6%8A%A5%E5%91%8A.pdf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatContentDisposition(tt.filename) + require.Equal(t, tt.want, got) + }) + } +} + +func writeTestFileWithLines(t *testing.T, lines []string) string { + t.Helper() + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "lines.txt") + content := "" + for i, l := range lines { + if i > 0 { + content += "\n" + } + content += l + } + require.NoError(t, os.WriteFile(target, []byte(content), 0o644)) + return target +} + +func TestDownloadFileLineBasedReading(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1", "line2", "line3", "line4", "line5"}) + rawURL := fmt.Sprintf("/files/download?path=%s&offset=2&limit=2", url.QueryEscape(target)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.DownloadFile() + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "line2\nline3", rec.Body.String()) + require.Equal(t, "text/plain; charset=utf-8", rec.Header().Get("Content-Type")) +} + +func TestDownloadFileLineBasedOffsetOnly(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1", "line2", "line3"}) + rawURL := fmt.Sprintf("/files/download?path=%s&offset=2", url.QueryEscape(target)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.DownloadFile() + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "line2\nline3", rec.Body.String()) +} + +func TestDownloadFileLimitOnly(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1", "line2", "line3"}) + rawURL := fmt.Sprintf("/files/download?path=%s&limit=2", url.QueryEscape(target)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.DownloadFile() + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "line1\nline2", rec.Body.String()) +} + +func TestDownloadFileOffsetBeyondEOF(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1", "line2"}) + rawURL := fmt.Sprintf("/files/download?path=%s&offset=100", url.QueryEscape(target)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.DownloadFile() + + require.Equal(t, http.StatusOK, rec.Code) + require.Empty(t, rec.Body.String()) +} + +func TestDownloadFileLimitBeyondRemaining(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1", "line2", "line3"}) + rawURL := fmt.Sprintf("/files/download?path=%s&offset=2&limit=100", url.QueryEscape(target)) + ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) + + ctrl.DownloadFile() + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "line2\nline3", rec.Body.String()) +} + +func TestDownloadFileLineRangeConflict(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1"}) + rawURL := fmt.Sprintf("/files/download?path=%s&offset=1&limit=1", url.QueryEscape(target)) + ctx, rec := newTestContext(http.MethodGet, rawURL, nil) + ctx.Request.Header.Set("Range", "bytes=0-10") + ctrl := NewFilesystemController(ctx) + + ctrl.DownloadFile() + + require.Equal(t, http.StatusBadRequest, rec.Code) + var resp model.ErrorResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, model.ErrorCodeInvalidRequest, resp.Code) +} + +func TestDownloadFileInvalidOffset(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1"}) + tests := []struct { + name string + rawURL string + }{ + {name: "non-numeric", rawURL: fmt.Sprintf("/files/download?path=%s&offset=abc", url.QueryEscape(target))}, + {name: "zero", rawURL: fmt.Sprintf("/files/download?path=%s&offset=0", url.QueryEscape(target))}, + {name: "negative", rawURL: fmt.Sprintf("/files/download?path=%s&offset=-1", url.QueryEscape(target))}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl, rec := newFilesystemController(t, http.MethodGet, tt.rawURL, nil) + ctrl.DownloadFile() + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} + +func TestDownloadFileInvalidLimit(t *testing.T) { + target := writeTestFileWithLines(t, []string{"line1"}) + tests := []struct { + name string + rawURL string + }{ + {name: "non-numeric", rawURL: fmt.Sprintf("/files/download?path=%s&limit=abc", url.QueryEscape(target))}, + {name: "zero", rawURL: fmt.Sprintf("/files/download?path=%s&limit=0", url.QueryEscape(target))}, + {name: "negative", rawURL: fmt.Sprintf("/files/download?path=%s&limit=-1", url.QueryEscape(target))}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl, rec := newFilesystemController(t, http.MethodGet, tt.rawURL, nil) + ctrl.DownloadFile() + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/components/execd/pkg/web/controller/filesystem_upload.go b/components/execd/pkg/web/controller/filesystem_upload.go new file mode 100644 index 0000000..594cdd2 --- /dev/null +++ b/components/execd/pkg/web/controller/filesystem_upload.go @@ -0,0 +1,234 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +type uploadError struct { + status int + code model.ErrorCode + message string +} + +func newUploadError(status int, code model.ErrorCode, message string) *uploadError { + return &uploadError{status: status, code: code, message: message} +} + +// UploadFile uploads files with metadata to specified paths +func (c *FilesystemController) UploadFile() { + rec := beginFilesystemMetric("upload") + defer rec.Finish(c.basicController) + + metadataParts, fileParts, uerr := c.parseUploadForm() + if uerr != nil { + c.RespondError(uerr.status, uerr.code, uerr.message) + return + } + + for i := range metadataParts { + if uerr := c.processUploadPair(metadataParts[i], fileParts[i]); uerr != nil { + c.RespondError(uerr.status, uerr.code, uerr.message) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +func (c *FilesystemController) parseUploadForm() ([]*multipart.FileHeader, []*multipart.FileHeader, *uploadError) { + return parseUploadForm(c.ctx) +} + +func parseUploadForm(ctx *gin.Context) ([]*multipart.FileHeader, []*multipart.FileHeader, *uploadError) { + form, err := ctx.MultipartForm() + if err != nil || form == nil { + return nil, nil, newUploadError(http.StatusBadRequest, model.ErrorCodeInvalidFile, "multipart form is empty") + } + + metadataParts := form.File["metadata"] + fileParts := form.File["file"] + + if len(metadataParts) == 0 { + return nil, nil, newUploadError(http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, "metadata file is missing") + } + if len(fileParts) == 0 { + return nil, nil, newUploadError(http.StatusBadRequest, model.ErrorCodeInvalidFileContent, "file is missing") + } + if len(metadataParts) != len(fileParts) { + return nil, nil, newUploadError( + http.StatusBadRequest, + model.ErrorCodeInvalidFile, + fmt.Sprintf("metadata and file count mismatch: %d vs %d", len(metadataParts), len(fileParts)), + ) + } + return metadataParts, fileParts, nil +} + +func (c *FilesystemController) processUploadPair(metadataHeader, fileHeader *multipart.FileHeader) *uploadError { + meta, uerr := parseUploadMetadata(metadataHeader) + if uerr != nil { + return uerr + } + + resolvedPath, uerr := resolveUploadTarget(meta.Path, meta.Permission) + if uerr != nil { + return uerr + } + + if uerr := writeUploadFile(resolvedPath, fileHeader); uerr != nil { + return uerr + } + + return applyUploadPermission(resolvedPath, meta.Permission) +} + +func parseUploadMetadata(header *multipart.FileHeader) (*model.FileMetadata, *uploadError) { + metadataFile, err := header.Open() + if err != nil { + return nil, newUploadError( + http.StatusBadRequest, + model.ErrorCodeInvalidFileMetadata, + fmt.Sprintf("error opening metadata file. %v", err), + ) + } + metaBytes, err := io.ReadAll(metadataFile) + metadataFile.Close() + if err != nil { + return nil, newUploadError( + http.StatusBadRequest, + model.ErrorCodeInvalidFileMetadata, + fmt.Sprintf("error reading metadata content. %v", err), + ) + } + + var meta model.FileMetadata + if err := json.Unmarshal(metaBytes, &meta); err != nil { + return nil, newUploadError( + http.StatusBadRequest, + model.ErrorCodeInvalidFileMetadata, + fmt.Sprintf("invalid metadata format. %v", err), + ) + } + if meta.Path == "" { + return nil, newUploadError(http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, "metadata path is empty") + } + return &meta, nil +} + +func resolveUploadTarget(targetPath string, perm model.Permission) (string, *uploadError) { + resolvedPath, err := pathutil.ExpandPath(targetPath) + if err != nil { + return "", newUploadError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error resolving target path %s. %v", targetPath, err), + ) + } + targetDir := filepath.Dir(resolvedPath) + if err := MkdirAllWithOwnership(targetDir, os.ModePerm, perm.Owner, perm.Group); err != nil { + return "", newUploadError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error creating target directory %s. %v", targetDir, err), + ) + } + return resolvedPath, nil +} + +func writeUploadFile(resolvedPath string, fileHeader *multipart.FileHeader) *uploadError { + file, err := fileHeader.Open() + if err != nil { + return newUploadError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error opening file %s. %v", fileHeader.Filename, err), + ) + } + defer file.Close() + + dst, err := os.OpenFile(resolvedPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) + if err != nil { + return newUploadError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error opening destination file %s. %v", resolvedPath, err), + ) + } + + if _, err := io.Copy(dst, file); err != nil { + dst.Close() + return newUploadError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error copying file %s. %v", resolvedPath, err), + ) + } + + if err := dst.Sync(); err != nil { + log.Error("failed to sync target file: %v", err) + } + if err := dst.Close(); err != nil { + log.Error("failed to close target file: %v", err) + } + + // fsync parent directory so the new dirent is durable and visible on + // weakly-coherent filesystems (virtio-fs, 9pfs, etc.). Best-effort: + // some filesystems return ENOTSUP for directory fsync. + targetDir := filepath.Dir(resolvedPath) + if d, err := os.Open(targetDir); err == nil { + if err := d.Sync(); err != nil { + log.Warn("failed to sync parent dir %s: %v", targetDir, err) + } + _ = d.Close() + } + return nil +} + +// applyUploadPermission applies the metadata permission with one retry to +// absorb metadata-propagation delay on weakly-coherent filesystems +// (virtio-fs, 9pfs). ChmodFile always invokes chown under the hood, so a +// freshly-created dirent that has not yet propagated will surface as ENOENT +// here even though the file is fully written and synced. +func applyUploadPermission(resolvedPath string, permission model.Permission) *uploadError { + chmodErr := ChmodFile(resolvedPath, permission) + if chmodErr != nil { + time.Sleep(20 * time.Millisecond) + chmodErr = ChmodFile(resolvedPath, permission) + } + if chmodErr != nil { + return newUploadError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error chmoding file %s. %v", resolvedPath, chmodErr), + ) + } + return nil +} diff --git a/components/execd/pkg/web/controller/filesystem_windows.go b/components/execd/pkg/web/controller/filesystem_windows.go new file mode 100644 index 0000000..e5fe3c0 --- /dev/null +++ b/components/execd/pkg/web/controller/filesystem_windows.go @@ -0,0 +1,491 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package controller + +import ( + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/util/glob" + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// FilesystemController handles file system operations. +type FilesystemController struct { + *basicController +} + +func NewFilesystemController(ctx *gin.Context) *FilesystemController { + return &FilesystemController{basicController: newBasicController(ctx)} +} + +func (c *FilesystemController) handleFileError(err error) { + if errors.Is(err, fs.ErrNotExist) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeFileNotFound, + fmt.Sprintf("file not found. %v", err), + ) + } else { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error accessing file: %v", err), + ) + } +} + +// GetFilesInfo retrieves metadata for specified file paths +func (c *FilesystemController) GetFilesInfo() { + rec := beginFilesystemMetric("info") + defer rec.Finish(c.basicController) + + paths := c.ctx.QueryArray("path") + if len(paths) == 0 { + rec.MarkSuccess() + c.RespondSuccess(make(map[string]model.FileInfo)) + return + } + + resp := make(map[string]model.FileInfo) + for _, filePath := range paths { + fileInfo, err := GetFileInfo(filePath) + if err != nil { + c.handleFileError(err) + return + } + resp[filePath] = fileInfo + } + + rec.MarkSuccess() + c.RespondSuccess(resp) +} + +// RemoveFiles deletes specified files +func (c *FilesystemController) RemoveFiles() { + rec := beginFilesystemMetric("delete") + defer rec.Finish(c.basicController) + + paths := c.ctx.QueryArray("path") + for _, filePath := range paths { + if err := DeleteFile(filePath); err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error removing file %s. %v", filePath, err), + ) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// ChmodFiles changes file permissions for specified files +func (c *FilesystemController) ChmodFiles() { + rec := beginFilesystemMetric("chmod") + defer rec.Finish(c.basicController) + + var request map[string]model.Permission + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + for file, item := range request { + err := ChmodFile(file, item) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error changing permissions for %s. %v", file, err), + ) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// RenameFiles renames or moves files to new paths +func (c *FilesystemController) RenameFiles() { + rec := beginFilesystemMetric("rename") + defer rec.Finish(c.basicController) + + var request []model.RenameFileItem + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + for _, renameItem := range request { + if err := RenameFile(renameItem); err != nil { + c.handleFileError(err) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// MakeDirs creates directories with specified permissions +func (c *FilesystemController) MakeDirs() { + rec := beginFilesystemMetric("mkdir") + defer rec.Finish(c.basicController) + + var request map[string]model.Permission + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + for dir, perm := range request { + if err := MakeDir(dir, perm); err != nil { + c.handleFileError(err) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// RemoveDirs recursively removes directories +func (c *FilesystemController) RemoveDirs() { + rec := beginFilesystemMetric("rmdir") + defer rec.Finish(c.basicController) + + paths := c.ctx.QueryArray("path") + for _, dir := range paths { + resolvedDir, err := pathutil.ExpandPath(dir) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error resolving directory %s. %v", dir, err), + ) + return + } + if err := os.RemoveAll(resolvedDir); err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error removing directory %s. %v", dir, err), + ) + return + } + } + + rec.MarkSuccess() + c.RespondSuccess(nil) +} + +// ListDirectory lists directory contents with optional depth control +func (c *FilesystemController) ListDirectory() { + rec := beginFilesystemMetric("listdir") + defer rec.Finish(c.basicController) + + path := c.ctx.Query("path") + if path == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'path'", + ) + return + } + + depth := 1 + if rawDepth := c.ctx.Query("depth"); rawDepth != "" { + parsedDepth, err := strconv.Atoi(rawDepth) + if err != nil || parsedDepth < 0 { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'depth': %s", rawDepth), + ) + return + } + depth = parsedDepth + } + + path, err := pathutil.ExpandAbsPath(path) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error converting path %s to absolute. %v", path, err), + ) + return + } + + // Use Lstat so a symlink passed as the root is detected and rejected + // rather than silently followed: /directories/list never traverses + // symlinks (see the public spec), so listing through a symlink-as-root + // would expose a different subtree than the caller asked for. + info, err := os.Lstat(path) + if err != nil { + c.handleFileError(err) + return + } + if info.Mode()&os.ModeSymlink != 0 { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("path is a symbolic link, refusing to traverse: %s", path), + ) + return + } + if !info.IsDir() { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("path is not a directory: %s", path), + ) + return + } + + entries, err := listDirectoryEntries(path, depth) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error listing directory %s. %v", path, err), + ) + return + } + + rec.MarkSuccess() + c.RespondSuccess(entries) +} + +func listDirectoryEntries(root string, maxDepth int) ([]model.FileInfo, error) { + entries := make([]model.FileInfo, 0, 16) + if maxDepth == 0 { + return entries, nil + } + + var walk func(string, int) error + walk = func(dir string, currentDepth int) error { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range dirEntries { + entryPath := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + return err + } + + entryInfo, err := buildFileInfo(entryPath, info) + if err != nil { + return err + } + entries = append(entries, entryInfo) + + if entry.IsDir() && currentDepth+1 < maxDepth { + if err := walk(entryPath, currentDepth+1); err != nil { + return err + } + } + } + return nil + } + + return entries, walk(root, 0) +} + +// SearchFiles searches for files matching a pattern in a directory +func (c *FilesystemController) SearchFiles() { + rec := beginFilesystemMetric("search") + defer rec.Finish(c.basicController) + + path := c.ctx.Query("path") + if path == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'path'", + ) + return + } + + path, err := pathutil.ExpandAbsPath(path) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error converting path %s to absolute. %v", path, err), + ) + return + } + + _, err = os.Stat(path) + if err != nil { + c.handleFileError(err) + return + } + + pattern := c.ctx.Query("pattern") + if pattern == "" { + pattern = "**" + } + + files := make([]model.FileInfo, 0, 16) + err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("error accessing path %s: %w", filePath, err) + } + if info.IsDir() { + return nil + } + + match, err := glob.PathMatch(pattern, info.Name()) + if err != nil { + return fmt.Errorf("invalid pattern %s: %w", pattern, err) + } + + if match { + fileInfo, err := buildFileInfo(filePath, info) + if err != nil { + return err + } + files = append(files, fileInfo) + } + + return nil + }) + + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error searching files. %v", err), + ) + return + } + + rec.MarkSuccess() + c.RespondSuccess(files) +} + +// ReplaceContent replaces text content in specified files +func (c *FilesystemController) ReplaceContent() { + rec := beginFilesystemMetric("replace") + defer rec.Finish(c.basicController) + + verbose := c.ctx.Query("verbose") == "true" + + var request map[string]model.ReplaceFileContentItem + if err := c.bindJSON(&request); err != nil { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), + ) + return + } + + var results map[string]model.ReplaceFileContentResult + if verbose { + results = make(map[string]model.ReplaceFileContentResult) + } + + for file, item := range request { + origPath := file + file, err := pathutil.ExpandAbsPath(file) + if err != nil { + c.handleFileError(err) + return + } + + if _, err = os.Stat(file); err != nil { + c.handleFileError(err) + return + } + + content, err := os.ReadFile(file) + if err != nil { + c.handleFileError(err) + return + } + + fileInfo, err := os.Stat(file) + if err != nil { + c.handleFileError(err) + return + } + mode := fileInfo.Mode() + + if item.Old == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "old content must not be empty") + return + } + + contentStr := string(content) + newContent := strings.ReplaceAll(contentStr, item.Old, item.New) + + err = os.WriteFile(file, []byte(newContent), mode) + if err != nil { + c.handleFileError(err) + return + } + + if verbose { + results[origPath] = model.ReplaceFileContentResult{ + ReplacedCount: strings.Count(contentStr, item.Old), + } + } + } + + rec.MarkSuccess() + if verbose { + c.RespondSuccess(results) + } else { + c.RespondSuccess(nil) + } +} diff --git a/components/execd/pkg/web/controller/isolated_session.go b/components/execd/pkg/web/controller/isolated_session.go new file mode 100644 index 0000000..322664d --- /dev/null +++ b/components/execd/pkg/web/controller/isolated_session.go @@ -0,0 +1,311 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/isolation" + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/telemetry" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// isolatedRunner is set by InitIsolatedRunner during startup. +var isolatedRunner *runtime.IsolatedRunner + +// isolatedProbeResult stores the probe result for capabilities reporting. +var isolatedProbeResult *isolation.ProbeResult + +// InitIsolatedRunner wires the isolated session runner. +func InitIsolatedRunner(r *runtime.IsolatedRunner) { + isolatedRunner = r +} + +// InitIsolatedProbe stores the probe result for the capabilities endpoint. +func InitIsolatedProbe(p *isolation.ProbeResult) { + isolatedProbeResult = p +} + +// IsolatedSessionController handles /v1/isolated/* endpoints. +type IsolatedSessionController struct { + *basicController +} + +// NewIsolatedSessionController creates a controller bound to ctx. +func NewIsolatedSessionController(ctx *gin.Context) *IsolatedSessionController { + return &IsolatedSessionController{ + basicController: newBasicController(ctx), + } +} + +func (c *IsolatedSessionController) probed() bool { + return isolatedRunner != nil && isolatedRunner.Available() +} + +// Create handles POST /v1/isolated/session. +func (c *IsolatedSessionController) Create() { + if !c.probed() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable") + return + } + + var req model.CreateIsolatedSessionRequest + if err := c.bindJSON(&req); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error()) + return + } + if err := req.Validate(); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error()) + return + } + + binds := make([]isolation.BindMount, 0, len(req.Binds)) + for _, b := range req.Binds { + binds = append(binds, isolation.BindMount{ + Source: b.Source, + Dest: b.Dest, + ReadOnly: b.ReadOnly, + }) + } + + opts := &runtime.IsolatedSessionOptions{ + Profile: req.Profile, + WorkspacePath: req.Workspace.Path, + WorkspaceMode: req.Workspace.Mode, + ExtraWritable: req.ExtraWritable, + Binds: binds, + ShareNet: req.ShareNet, + EnvPassthroughMode: req.EnvPassthrough.Mode, + EnvPassthroughKeys: req.EnvPassthrough.Keys, + Uid: req.Uid, + Gid: req.Gid, + UidMode: req.UidMode, + IdleTimeoutSeconds: req.IdleTimeoutSeconds, + } + + sessionID, err := isolatedRunner.CreateIsolatedSession(opts) + if err != nil { + status := http.StatusInternalServerError + if strings.Contains(err.Error(), "not in allowlist") || + strings.Contains(err.Error(), "not allowed") || + strings.Contains(err.Error(), "unknown isolation profile") || + strings.Contains(err.Error(), "must be an existing path") || + strings.Contains(err.Error(), "must be an absolute path") || + strings.Contains(err.Error(), "source is required") { + status = http.StatusBadRequest + } + c.RespondError(status, model.ErrorCodeRuntimeError, err.Error()) + return + } + + c.ctx.JSON(http.StatusCreated, model.IsolatedCreateSessionResponse{ + SessionID: sessionID, + CreatedAt: time.Now(), + }) +} + +// Get handles GET /v1/isolated/session/:sessionId. +func (c *IsolatedSessionController) Get() { + if !c.probed() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable") + return + } + + sessionID := c.ctx.Param("sessionId") + state, err := isolatedRunner.GetIsolatedSession(sessionID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found") + return + } + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + + c.RespondSuccess(model.SessionState{ + Status: state.Status, + CreatedAt: state.CreatedAt, + LastRunAt: state.LastRunAt, + IdleRemainingSeconds: state.IdleRemainingSeconds, + }) +} + +// List handles GET /v1/isolated/sessions. +func (c *IsolatedSessionController) List() { + if !c.probed() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable") + return + } + + sessions := isolatedRunner.ListIsolatedSessions() + items := make([]model.IsolatedSessionSummary, 0, len(sessions)) + for _, s := range sessions { + items = append(items, model.IsolatedSessionSummary{ + SessionID: s.SessionID, + Status: s.Status, + CreatedAt: s.CreatedAt, + LastRunAt: s.LastRunAt, + IdleRemainingSeconds: s.IdleRemainingSeconds, + }) + } + + c.RespondSuccess(model.ListIsolatedSessionsResponse{Sessions: items}) +} + +// Run handles POST /v1/isolated/session/:sessionId/run (SSE streaming). +func (c *IsolatedSessionController) Run() { + if !c.probed() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable") + return + } + + sessionID := c.ctx.Param("sessionId") + + var req model.IsolatedRunRequest + if err := c.bindJSON(&req); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error()) + return + } + if err := req.Validate(); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error()) + return + } + + var ctx context.Context + var cancel context.CancelFunc + if req.TimeoutSeconds > 0 { + ctx, cancel = context.WithTimeout(c.ctx.Request.Context(), time.Duration(req.TimeoutSeconds)*time.Second) + } else { + ctx, cancel = context.WithCancel(c.ctx.Request.Context()) + } + defer cancel() + + // SSE stdout callback. + onStdout := func(line string) { + if line == "" { + return + } + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeStdout, + Text: line, + Timestamp: time.Now().UnixMilli(), + } + c.writeSingleEvent("IsolatedStdout", event.ToJSON(), false, event.Summary()) + } + + startTime := time.Now() + err := isolatedRunner.RunInIsolatedSession(ctx, sessionID, req.Code, req.Envs, onStdout) + durationMs := float64(time.Since(startTime)) / float64(time.Millisecond) + + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found") + return + } + telemetry.RecordIsolatedRun(ctx, "error", durationMs) + ename := "RuntimeError" + evalue := err.Error() + if strings.HasPrefix(evalue, "command exited with code ") { + ename = "ExitError" + evalue = strings.TrimPrefix(evalue, "command exited with code ") + } + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeError, + Text: err.Error(), + Timestamp: time.Now().UnixMilli(), + Error: &execute.ErrorOutput{ + EName: ename, + EValue: evalue, + }, + } + c.writeSingleEvent("IsolatedError", event.ToJSON(), true, event.Summary()) + return + } + telemetry.RecordIsolatedRun(ctx, "success", durationMs) + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeComplete, + Timestamp: time.Now().UnixMilli(), + } + c.writeSingleEvent("IsolatedComplete", event.ToJSON(), true, event.Summary()) +} + +// Delete handles DELETE /v1/isolated/session/:sessionId. +func (c *IsolatedSessionController) Delete() { + if !c.probed() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable") + return + } + + sessionID := c.ctx.Param("sessionId") + if err := isolatedRunner.DeleteIsolatedSession(sessionID); err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found") + return + } + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + + c.RespondSuccess(nil) +} + +// Diff handles GET /v1/isolated/session/:sessionId/diff. +func (c *IsolatedSessionController) Diff() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeNotSupported, "diff not implemented yet (phase 2)") +} + +// Commit handles POST /v1/isolated/session/:sessionId/commit. +func (c *IsolatedSessionController) Commit() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeNotSupported, "commit not implemented yet (phase 2)") +} + +// Capabilities handles GET /v1/isolated/capabilities. +func (c *IsolatedSessionController) Capabilities() { + if isolatedRunner == nil { + resp := model.CapabilitiesResponse{ + Available: false, + CommitSupported: false, + DiffSupported: false, + } + if isolatedProbeResult != nil { + resp.Message = isolatedProbeResult.Message + } + c.RespondSuccess(resp) + return + } + caps := isolatedRunner.Capabilities() + resp := model.CapabilitiesResponse{ + Available: caps.Available, + Isolator: caps.Isolator, + Version: caps.Version, + CommitSupported: caps.CommitSupported, + DiffSupported: caps.DiffSupported, + } + // Probe results indicate overlay capability, not diff/commit implementation. + // Diff and commit are Phase 2; do not advertise them as supported. + resp.CommitSupported = false + resp.DiffSupported = false + c.RespondSuccess(resp) +} + +// Filesystem proxy handlers are in isolated_session_files.go. diff --git a/components/execd/pkg/web/controller/isolated_session_files.go b/components/execd/pkg/web/controller/isolated_session_files.go new file mode 100644 index 0000000..101fee2 --- /dev/null +++ b/components/execd/pkg/web/controller/isolated_session_files.go @@ -0,0 +1,563 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/vfs" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +func (c *IsolatedSessionController) getMergedView() (vfs.FS, error) { + if isolatedRunner == nil || !isolatedRunner.Available() { + c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable") + return nil, fmt.Errorf("isolation unavailable") + } + sessionID := c.ctx.Param("sessionId") + mv, err := isolatedRunner.GetMergedView(sessionID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found") + return nil, err + } + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return nil, err + } + if mv == nil { + c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found") + return nil, fmt.Errorf("no merged view") + } + return mv, nil +} + +func (c *IsolatedSessionController) GetFilesInfo() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + paths := c.ctx.QueryArray("path") + if len(paths) == 0 { + c.RespondSuccess(make(map[string]model.FileInfo)) + return + } + + resp := make(map[string]model.FileInfo) + for _, filePath := range paths { + cleaned := filepath.Clean(filePath) + info, err := mv.Stat(cleaned) + if err != nil { + if os.IsNotExist(err) { + c.RespondError(http.StatusNotFound, model.ErrorCodeFileNotFound, err.Error()) + } else { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + } + return + } + resp[filePath] = buildIsolatedFileInfo(filePath, info) + } + c.RespondSuccess(resp) +} + +func (c *IsolatedSessionController) SearchFiles() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + path := c.ctx.Query("path") + if path == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'path'") + return + } + + pattern := c.ctx.Query("pattern") + if pattern == "" { + pattern = "**" + } + + results, err := mv.Search(path, pattern) + if err != nil { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + + files := make([]model.FileInfo, 0, len(results)) + for _, rel := range results { + info, err := mv.Stat(rel) + if err != nil { + continue + } + files = append(files, buildIsolatedFileInfo(rel, info)) + } + c.RespondSuccess(files) +} + +func (c *IsolatedSessionController) DownloadFile() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + filePath := c.ctx.Query("path") + if filePath == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeMissingQuery, "path is required") + return + } + + rawOffset := c.ctx.Query("offset") + rawLimit := c.ctx.Query("limit") + hasLineParams := rawOffset != "" || rawLimit != "" + rangeHeader := c.ctx.GetHeader("Range") + + if hasLineParams && rangeHeader != "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + "line-based reading (offset/limit) and byte range (Range header) are mutually exclusive") + return + } + + f, err := mv.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + c.RespondError(http.StatusNotFound, model.ErrorCodeFileNotFound, err.Error()) + return + } + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + defer f.Close() + + if hasLineParams { + c.serveIsolatedLineRange(f, rawOffset, rawLimit) + return + } + + fileInfo, err := f.Stat() + if err != nil { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + + c.ctx.Header("Content-Type", "application/octet-stream") + c.ctx.Header("Content-Disposition", formatContentDisposition(filepath.Base(filePath))) + c.ctx.Header("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) + + if rangeHeader != "" { + ranges, err := ParseRange(rangeHeader, fileInfo.Size()) + if err != nil { + c.RespondError(http.StatusRequestedRangeNotSatisfiable, model.ErrorCodeUnknown) + return + } + if len(ranges) > 0 { + r := ranges[0] + c.ctx.Status(http.StatusPartialContent) + c.ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, fileInfo.Size())) + c.ctx.Header("Content-Length", strconv.FormatInt(r.length, 10)) + + _, _ = f.Seek(r.start, io.SeekStart) + _, _ = io.CopyN(c.ctx.Writer, f, r.length) + return + } + } + + http.ServeContent(c.ctx.Writer, c.ctx.Request, filepath.Base(filePath), fileInfo.ModTime(), f) +} + +func (c *IsolatedSessionController) serveIsolatedLineRange(file *os.File, rawOffset, rawLimit string) { + offset := int64(1) + if rawOffset != "" { + parsed, err := strconv.ParseInt(rawOffset, 10, 64) + if err != nil || parsed < 1 { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'offset': %s", rawOffset)) + return + } + offset = parsed + } + + limit := int64(-1) + if rawLimit != "" { + parsed, err := strconv.ParseInt(rawLimit, 10, 64) + if err != nil || parsed < 1 { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'limit': %s", rawLimit)) + return + } + limit = parsed + } + + c.ctx.Header("Content-Type", "text/plain; charset=utf-8") + c.ctx.Status(http.StatusOK) + + reader := bufio.NewReader(file) + var lineNum int64 + var written int64 + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + line = bytes.TrimRight(line, "\r\n") + lineNum++ + if lineNum >= offset { + if written > 0 { + _, _ = c.ctx.Writer.Write([]byte("\n")) + } + _, _ = c.ctx.Writer.Write(line) + written++ + if limit >= 0 && written >= limit { + break + } + } + } + if err != nil { + break + } + } +} + +func (c *IsolatedSessionController) UploadFile() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + metadataParts, fileParts, uerr := parseUploadForm(c.ctx) + if uerr != nil { + c.RespondError(uerr.status, uerr.code, uerr.message) + return + } + + for i := range metadataParts { + meta, uerr := parseUploadMetadata(metadataParts[i]) + if uerr != nil { + c.RespondError(uerr.status, uerr.code, uerr.message) + return + } + + file, err := fileParts[i].Open() + if err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidFile, err.Error()) + return + } + + filePath := filepath.Clean(meta.Path) + dir := filepath.Dir(filePath) + if dir != "." && dir != "/" { + if err := mv.MkdirAll(dir, 0o755); err != nil { + log.Warn("isolated upload: mkdir %s: %v", dir, err) + } + } + + perm := os.FileMode(0o644) + if meta.Permission.Mode != 0 { + mode, convErr := strconv.ParseUint(strconv.Itoa(meta.Permission.Mode), 8, 32) + if convErr == nil { + perm = os.FileMode(mode) + } + } + + if _, err := mv.WriteFileReader(filePath, file, perm); err != nil { + file.Close() + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + file.Close() + } + + c.RespondSuccess(nil) +} + +func (c *IsolatedSessionController) RemoveFiles() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + paths := c.ctx.QueryArray("path") + for _, p := range paths { + if err := mv.Remove(p); err != nil { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + } + c.RespondSuccess(nil) +} + +func (c *IsolatedSessionController) RenameFiles() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + var request []model.RenameFileItem + if err := c.bindJSON(&request); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err)) + return + } + + for _, item := range request { + if err := mv.Rename(item.Src, item.Dest); err != nil { + if os.IsNotExist(err) { + c.RespondError(http.StatusNotFound, model.ErrorCodeFileNotFound, err.Error()) + } else { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + } + return + } + } + c.RespondSuccess(nil) +} + +func (c *IsolatedSessionController) ChmodFiles() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + var request map[string]model.Permission + if err := c.bindJSON(&request); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err)) + return + } + + for file, item := range request { + if item.Mode != 0 { + mode, err := strconv.ParseUint(strconv.Itoa(item.Mode), 8, 32) + if err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid mode for %s: %v", file, err)) + return + } + if err := mv.Chmod(file, os.FileMode(mode)); err != nil { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + } + } + c.RespondSuccess(nil) +} + +func (c *IsolatedSessionController) ReplaceContent() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + verbose := c.ctx.Query("verbose") == "true" + + var request map[string]model.ReplaceFileContentItem + if err := c.bindJSON(&request); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err)) + return + } + + var results map[string]model.ReplaceFileContentResult + if verbose { + results = make(map[string]model.ReplaceFileContentResult) + } + + for file, item := range request { + if item.Old == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "old content must not be empty") + return + } + + data, err := mv.ReadFile(file) + if err != nil { + if os.IsNotExist(err) { + c.RespondError(http.StatusNotFound, model.ErrorCodeFileNotFound, err.Error()) + } else { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + } + return + } + + contentStr := string(data) + if verbose { + results[file] = model.ReplaceFileContentResult{ + ReplacedCount: strings.Count(contentStr, item.Old), + } + } + + newContent := strings.ReplaceAll(contentStr, item.Old, item.New) + origInfo, _ := mv.Stat(file) + perm := os.FileMode(0o644) + if origInfo != nil { + perm = origInfo.Mode().Perm() + } + if err := mv.WriteFile(file, []byte(newContent), perm); err != nil { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + } + + if verbose { + c.RespondSuccess(results) + } else { + c.RespondSuccess(nil) + } +} + +func (c *IsolatedSessionController) MakeDirs() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + var request map[string]model.Permission + if err := c.bindJSON(&request); err != nil { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err)) + return + } + + for dir, perm := range request { + mode := os.FileMode(0o755) + if perm.Mode != 0 { + parsed, err := strconv.ParseUint(strconv.Itoa(perm.Mode), 8, 32) + if err == nil { + mode = os.FileMode(parsed) + } + } + if err := mv.MkdirAll(dir, mode); err != nil { + if os.IsNotExist(err) { + c.RespondError(http.StatusNotFound, model.ErrorCodeFileNotFound, err.Error()) + } else { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + } + return + } + } + c.RespondSuccess(nil) +} + +func (c *IsolatedSessionController) RemoveDirs() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + paths := c.ctx.QueryArray("path") + for _, p := range paths { + if err := mv.RemoveAll(p); err != nil { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + return + } + } + c.RespondSuccess(nil) +} + +func (c *IsolatedSessionController) ListDirectory() { + mv, _ := c.getMergedView() + if mv == nil { + return + } + + path := c.ctx.Query("path") + if path == "" { + c.RespondError(http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'path'") + return + } + + depth := 1 + if rawDepth := c.ctx.Query("depth"); rawDepth != "" { + parsedDepth, err := strconv.Atoi(rawDepth) + if err != nil || parsedDepth < 0 { + c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, + fmt.Sprintf("invalid query parameter 'depth': %s", rawDepth)) + return + } + depth = parsedDepth + } + + entries, err := c.listIsolatedDir(mv, path, depth) + if err != nil { + if os.IsNotExist(err) { + c.RespondError(http.StatusNotFound, model.ErrorCodeFileNotFound, err.Error()) + } else { + c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error()) + } + return + } + c.RespondSuccess(entries) +} + +func (c *IsolatedSessionController) listIsolatedDir(mv vfs.FS, root string, maxDepth int) ([]model.FileInfo, error) { + entries := make([]model.FileInfo, 0, 16) + if maxDepth == 0 { + return entries, nil + } + + var walk func(string, int) error + walk = func(dir string, currentDepth int) error { + dirEntries, err := mv.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range dirEntries { + entryPath := filepath.Join(dir, entry.Name()) + info, err := entry.Info() + if err != nil { + return err + } + entries = append(entries, buildIsolatedFileInfo(entryPath, info)) + if entry.IsDir() && currentDepth+1 < maxDepth { + if err := walk(entryPath, currentDepth+1); err != nil { + return err + } + } + } + return nil + } + + return entries, walk(root, 0) +} + +func buildIsolatedFileInfo(path string, info os.FileInfo) model.FileInfo { + ft := "file" + mode := info.Mode() + if mode&os.ModeSymlink != 0 { + ft = "symlink" + } else if info.IsDir() { + ft = "directory" + } + + modeStr := strconv.FormatInt(int64(mode.Perm()), 8) + modeInt, _ := strconv.Atoi(modeStr) + + return model.FileInfo{ + Path: path, + Type: ft, + Size: info.Size(), + ModifiedAt: info.ModTime(), + Permission: model.Permission{ + Mode: modeInt, + }, + } +} diff --git a/components/execd/pkg/web/controller/metric.go b/components/execd/pkg/web/controller/metric.go new file mode 100644 index 0000000..5ae2d3f --- /dev/null +++ b/components/execd/pkg/web/controller/metric.go @@ -0,0 +1,111 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + "time" + + "github.com/gin-gonic/gin" + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/mem" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// MetricController handles system metrics requests +type MetricController struct { + *basicController +} + +func NewMetricController(ctx *gin.Context) *MetricController { + return &MetricController{basicController: newBasicController(ctx)} +} + +// GetMetrics returns current system metrics +func (c *MetricController) GetMetrics() { + metrics, err := c.readMetrics() + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error reading runtime metrics. %v", err), + ) + return + } + + c.RespondSuccess(metrics) +} + +// WatchMetrics streams system metrics via SSE +func (c *MetricController) WatchMetrics() { + c.setupSSEResponse() + + for { + select { + case <-c.ctx.Request.Context().Done(): + return + case <-time.After(time.Second * 1): + func() { + if flusher, ok := c.ctx.Writer.(http.Flusher); ok { + defer flusher.Flush() + } + metrics, err := c.readMetrics() + if err != nil { + msg, _ := json.Marshal(map[string]string{ //nolint:errchkjson + "error": err.Error(), + }) + _, err = c.ctx.Writer.Write(append(msg, '\n')) + if err != nil { + log.Error("WatchMetrics write data %s error: %v", string(msg), err) + } + } else { + msg, _ := json.Marshal(metrics) //nolint:errchkjson + _, err = c.ctx.Writer.Write(append(msg, '\n')) + if err != nil { + log.Error("WatchMetrics write data %s error: %v", string(msg), err) + } + } + }() + } + } +} + +// readMetrics collects current CPU and memory metrics +func (c *MetricController) readMetrics() (*model.Metrics, error) { + metric := model.NewMetrics() + + metric.CpuCount = float64(runtime.GOMAXPROCS(-1)) + cpuPercent, err := cpu.Percent(time.Second, false) + if err != nil { + return nil, fmt.Errorf("failed to get CPU percent: %w", err) + } + if len(cpuPercent) > 0 { + metric.CpuUsedPct = cpuPercent[0] + } + + vmStat, err := mem.VirtualMemory() + if err != nil { + return nil, fmt.Errorf("failed to get memory info: %w", err) + } + metric.MemTotalMiB = float64(vmStat.Total) / 1024 / 1024 + metric.MemUsedMiB = float64(vmStat.Used) / 1024 / 1024 + + return metric, nil +} diff --git a/components/execd/pkg/web/controller/metric_test.go b/components/execd/pkg/web/controller/metric_test.go new file mode 100644 index 0000000..3974dae --- /dev/null +++ b/components/execd/pkg/web/controller/metric_test.go @@ -0,0 +1,131 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +func setupMetricController(method, path string) (*MetricController, *httptest.ResponseRecorder) { + ctx, w := newTestContext(method, path, nil) + ctrl := NewMetricController(ctx) + return ctrl, w +} + +// TestReadMetrics exercises readMetrics end-to-end. +func TestReadMetrics(t *testing.T) { + ctrl := &MetricController{} + + metrics, err := ctrl.readMetrics() + + assert.NoError(t, err) + assert.NotNil(t, metrics) + + // Validate CPU count + assert.Greater(t, metrics.CpuCount, 0.0) + + // Validate CPU utilization + assert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0) + assert.Less(t, metrics.CpuUsedPct, 100.1) // CPU usage should be under 100% with small float tolerance + + // Validate memory information + assert.Greater(t, metrics.MemTotalMiB, 0.0) + assert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0) + assert.LessOrEqual(t, metrics.MemUsedMiB, metrics.MemTotalMiB) // Used memory should not exceed total + + // Validate timestamps + currentTime := time.Now().UnixMilli() + oneMinuteAgo := currentTime - 60*1000 + assert.GreaterOrEqual(t, metrics.Timestamp, oneMinuteAgo) // Should be within the last minute + assert.LessOrEqual(t, metrics.Timestamp, currentTime) // Should not be in the future +} + +// TestGetMetricsEndpoint covers the happy path. +func TestGetMetricsEndpoint(t *testing.T) { + ctrl, w := setupMetricController("GET", "/api/metrics") + + ctrl.GetMetrics() + + assert.Equal(t, http.StatusOK, w.Code) + + var metrics model.Metrics + err := json.Unmarshal(w.Body.Bytes(), &metrics) + assert.NoError(t, err) + + assert.Greater(t, metrics.CpuCount, 0.0) + assert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0) + assert.Greater(t, metrics.MemTotalMiB, 0.0) + assert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0) + assert.NotZero(t, metrics.Timestamp) +} + +// TestWatchMetricsHeaders verifies SSE header defaults. +func TestWatchMetricsHeaders(t *testing.T) { + ctrl, w := setupMetricController("GET", "/api/watch-metrics") + + ctrl.setupSSEResponse() + + contentType := w.Header().Get("Content-Type") + assert.Equal(t, "text/event-stream", contentType) + + cacheControl := w.Header().Get("Cache-Control") + assert.Equal(t, "no-cache", cacheControl) + + connection := w.Header().Get("Connection") + assert.Equal(t, "keep-alive", connection) + + buffering := w.Header().Get("X-Accel-Buffering") + assert.Equal(t, "no", buffering) +} + +// TestMetricSerialization ensures metrics marshal and unmarshal cleanly. +func TestMetricSerialization(t *testing.T) { + metrics := &model.Metrics{ + CpuCount: 4, + CpuUsedPct: 25.5, + MemTotalMiB: 8192, + MemUsedMiB: 4096, + Timestamp: time.Now().UnixMilli(), + } + + data, err := json.Marshal(metrics) + assert.NoError(t, err) + + var decodedMetrics model.Metrics + err = json.Unmarshal(data, &decodedMetrics) + assert.NoError(t, err) + assert.Equal(t, metrics.CpuCount, decodedMetrics.CpuCount) + assert.Equal(t, metrics.CpuUsedPct, decodedMetrics.CpuUsedPct) + assert.Equal(t, metrics.MemTotalMiB, decodedMetrics.MemTotalMiB) + assert.Equal(t, metrics.MemUsedMiB, decodedMetrics.MemUsedMiB) + assert.Equal(t, metrics.Timestamp, decodedMetrics.Timestamp) + + errorMsg := map[string]string{"error": "test error"} + errorData, err := json.Marshal(errorMsg) + assert.NoError(t, err) + + var decodedError map[string]string + err = json.Unmarshal(errorData, &decodedError) + assert.NoError(t, err) + assert.Equal(t, "test error", decodedError["error"]) +} diff --git a/components/execd/pkg/web/controller/ping.go b/components/execd/pkg/web/controller/ping.go new file mode 100644 index 0000000..6fd1075 --- /dev/null +++ b/components/execd/pkg/web/controller/ping.go @@ -0,0 +1,36 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import "github.com/gin-gonic/gin" + +// MainController handles basic server operations. +type MainController struct { + *basicController +} + +func NewMainController(ctx *gin.Context) *MainController { + return &MainController{basicController: newBasicController(ctx)} +} + +// Ping checks if the server is alive. +func (c *MainController) Ping() { + c.RespondSuccess(nil) +} + +// PingHandler is the Gin adapter. +func PingHandler(ctx *gin.Context) { + NewMainController(ctx).Ping() +} diff --git a/components/execd/pkg/web/controller/pty_controller.go b/components/execd/pkg/web/controller/pty_controller.go new file mode 100644 index 0000000..375845c --- /dev/null +++ b/components/execd/pkg/web/controller/pty_controller.go @@ -0,0 +1,158 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// PTYController handles /pty/* REST endpoints. +type PTYController struct { + *basicController +} + +// NewPTYController creates a new PTYController from the current Gin context. +func NewPTYController(ctx *gin.Context) *PTYController { + return &PTYController{basicController: newBasicController(ctx)} +} + +// CreatePTYSession handles POST /pty. +// Creates a new PTY session and returns its session_id. +func (c *PTYController) CreatePTYSession() { + if !runtime.IsPTYSessionSupported() { + c.RespondError( + http.StatusNotImplemented, + model.ErrorCodeNotSupported, + "pty sessions are not supported on this platform", + ) + return + } + + var req model.CreatePTYSessionRequest + if err := c.bindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeInvalidRequest, + fmt.Sprintf("error parsing request: %v", err), + ) + return + } + + id := runtime.NewPTYSessionID() + _, err := codeRunner.CreatePTYSession(id, req.Cwd, req.Command) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error creating pty session: %v", err), + ) + } + c.ctx.JSON(http.StatusCreated, model.CreatePTYSessionResponse{SessionID: id}) +} + +// GetPTYSessionStatus handles GET /pty/:sessionId. +func (c *PTYController) GetPTYSessionStatus() { + if !runtime.IsPTYSessionSupported() { + c.RespondError( + http.StatusNotImplemented, + model.ErrorCodeNotSupported, + "pty sessions are not supported on this platform", + ) + return + } + + id := c.ctx.Param("sessionId") + if id == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + running, offset, err := codeRunner.GetPTYSessionStatus(id) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("pty session %s not found", id), + ) + return + } + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error getting pty session status: %v", err), + ) + return + } + + c.RespondSuccess(model.PTYSessionStatusResponse{ + SessionID: id, + Running: running, + OutputOffset: offset, + }) +} + +// DeletePTYSession handles DELETE /pty/:sessionId. +func (c *PTYController) DeletePTYSession() { + if !runtime.IsPTYSessionSupported() { + c.RespondError( + http.StatusNotImplemented, + model.ErrorCodeNotSupported, + "pty sessions are not supported on this platform", + ) + return + } + + id := c.ctx.Param("sessionId") + if id == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'sessionId'", + ) + return + } + + if err := codeRunner.DeletePTYSession(id); err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("pty session %s not found", id), + ) + return + } + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting pty session: %v", err), + ) + return + } + + c.RespondSuccess(nil) +} diff --git a/components/execd/pkg/web/controller/pty_ws.go b/components/execd/pkg/web/controller/pty_ws.go new file mode 100644 index 0000000..da20f29 --- /dev/null +++ b/components/execd/pkg/web/controller/pty_ws.go @@ -0,0 +1,468 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/alibaba/opensandbox/internal/safego" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + // Allow all origins — execd runs behind a trusted reverse proxy. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +const ( + wsPingInterval = 30 * time.Second + wsReadDeadline = 60 * time.Second + wsWriteDeadline = 10 * time.Second + // wsTakeoverTimeout bounds how long a ?takeover=1 request waits for the current + // holder to release after being evicted, before giving up with 409. + wsTakeoverTimeout = 5 * time.Second + // wsTakeoverCloseTimeout bounds the best-effort close-frame write sent to an + // evicted holder, so a full/unresponsive client socket cannot stall the takeover. + wsTakeoverCloseTimeout = 200 * time.Millisecond +) + +// PTYSessionWebSocket handles GET /pty/:sessionId/ws. +// +// 1. Look up session → 404 before upgrade if missing +// 2. Acquire WS lock without eviction → 409 if held and not a ?takeover=1 request +// 3. Upgrade HTTP → WebSocket +// 3b. Takeover (if requested): evict the holder and acquire — only now that the +// handshake is accepted, so a failed upgrade never evicts anyone +// 4. Start bash if not already running +// 5+6. AtomicAttachOutputWithSnapshot (snapshot + attach under outMu — no loss window) +// 7. defer: detach → pumpWg.Wait → UnlockWS → ClearEvictHandler (hook live through cleanup) +// Register close-only eviction hook (before initial writes, so a stalled replay can +// be interrupted without a connMu race) +// 8. Send replay frame if snapshot non-empty +// 9. Send connected frame, then upgrade hook to full evictClose+cancelOnce +// (initial writes done; all subsequent writes serialized by connMu) +// 10. Start RFC 6455 ping, streamPump(s), exitWatcher goroutines +// 11. Read loop: dispatch client frames +func PTYSessionWebSocket(ctx *gin.Context) { + id := ctx.Param("sessionId") + if id == "" { + ctx.JSON(http.StatusBadRequest, model.ErrorResponse{ + Code: model.ErrorCodeMissingQuery, + Message: "missing path parameter 'sessionId'", + }) + return + } + + // 1. Look up session — must happen before upgrade so we can return HTTP errors. + session := codeRunner.GetPTYSession(id) + if session == nil { + ctx.JSON(http.StatusNotFound, model.ErrorResponse{ + Code: model.ErrorCodeContextNotFound, + Message: "pty session " + id + " not found", + }) + return + } + + // 2. Decide how to acquire the exclusive WS lock. Try without evicting first; a + // plain "already connected" with no takeover is refused with HTTP 409 *before* + // the upgrade. A ?takeover=1 request (on a real WS handshake) instead evicts the + // current holder — but only AFTER the handshake is fully accepted (step 3b), so a + // request that announces an upgrade yet fails the handshake never evicts anyone. + locked := session.LockWS() + wantsTakeover := !locked && ctx.Query("takeover") == "1" && websocket.IsWebSocketUpgrade(ctx.Request) + if !locked && !wantsTakeover { + ctx.JSON(http.StatusConflict, model.ErrorResponse{ + Code: model.WSErrCodeAlreadyConnected, + Message: "another client is already connected to pty session " + id, + }) + return + } + + // 3. Upgrade HTTP connection to WebSocket. For a takeover this happens BEFORE + // evicting, so a bad or incomplete handshake cannot kill the current holder. + conn, err := wsUpgrader.Upgrade(ctx.Writer, ctx.Request, nil) + if err != nil { + log.Warn("pty ws upgrade failed for session %s: %v", id, err) + if locked { + session.UnlockWS() + } + return + } + + // 3b. Takeover: the handshake succeeded, so now evict the current holder and + // acquire the lock. The shell keeps running; this client reattaches with replay. + if !locked { + if !session.TakeoverWS(wsTakeoverTimeout) { + writeErrFrame(conn, model.WSErrCodeAlreadyConnected, + "takeover timed out for pty session "+id) + _ = conn.Close() + return + } + } + // From here we hold the lock; it is released at the very end of this function (see + // defer below), only after all pump goroutines have exited. + + // Resolve query parameters. + pipeMode := ctx.Query("pty") == "0" + since := queryInt64(ctx.Query("since"), 0) + + // 4. Start bash if not already running. + if !session.IsRunning() { + var startErr error + if pipeMode { + startErr = session.StartPipe() + } else { + startErr = session.StartPTY() + } + if startErr != nil { + log.Warn("pty start failed for session %s: %v", id, startErr) + writeErrFrame(conn, model.WSErrCodeStartFailed, startErr.Error()) + _ = conn.Close() + session.UnlockWS() + return + } + } + + // 5+6. Atomically snapshot replay buffer and attach live pipe — eliminates the + // output-loss window where bytes written between ReadFrom and AttachOutput + // would be dropped by fanout (stdoutW still nil) yet missed by snapshot. + stdoutR, stderrR, detach, snapshotBytes, snapshotOffset := session.AttachOutputWithSnapshot(since) + + // 7. Deferred cleanup order: detach writers → wait for pump goroutines → unlock WS + // → clear our eviction hook. The hook is cleared LAST (after UnlockWS) so it stays + // live throughout cleanup: a ?takeover=1 arriving while a pump is still blocked + // writing to a dead client can then fire it, and closing the conn unblocks that + // pump so pumpWg.Wait() returns. ClearEvictHandler is generation-guarded, so it + // never clears a successor's hook; a zero evictGen (hook never registered, e.g. an + // early return below) is a no-op. + var pumpWg sync.WaitGroup + var evictGen uint64 + defer func() { + detach() + pumpWg.Wait() + session.UnlockWS() + session.ClearEvictHandler(evictGen) + }() + + // cancelCh is closed to signal all goroutines to stop. + cancelCh := make(chan struct{}) + cancelOnce := sync.OnceFunc(func() { close(cancelCh) }) + + // connMu serialises all writes to conn (gorilla/websocket requires single-writer). + var connMu sync.Mutex + + writeJSON := func(v any) error { + connMu.Lock() + defer connMu.Unlock() + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + return conn.WriteJSON(v) + } + + closeConn := func(code int, text string) { + connMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + _ = conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(code, text)) + connMu.Unlock() + _ = conn.Close() + } + + // evictClose is the non-blocking close used by the takeover eviction hook. Unlike + // closeConn it must not wait behind a stalled output writer: a takeover targets + // exactly the slow/abandoned-client case, so blocking on connMu (held by a pump + // stuck in WriteMessage) until wsWriteDeadline would defeat wsTakeoverTimeout. It + // therefore sends the close frame only on a best-effort basis (TryLock, short + // deadline) and always closes the conn, which unblocks any stuck writer. A client + // too backed-up to receive the frame could not have received it anyway. + evictClose := func(code int, text string) { + if connMu.TryLock() { + _ = conn.SetWriteDeadline(time.Now().Add(wsTakeoverCloseTimeout)) + _ = conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(code, text)) + connMu.Unlock() + } + _ = conn.Close() + } + + // Set initial read deadline; pong handler resets it. + _ = conn.SetReadDeadline(time.Now().Add(wsReadDeadline)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(wsReadDeadline)) + }) + + // Register a close-only eviction hook BEFORE the initial writes so a concurrent + // ?takeover=1 can interrupt a holder stalled during a large replay or the connected + // frame. At this point the initial writes run without connMu (pumps not yet started), + // so the hook must not acquire connMu or write to conn — only close it. Closing + // unblocks any blocked WriteMessage and makes the subsequent read loop exit, which + // triggers the deferred cleanup and releases the lock. cancelOnce is also called so + // all goroutines stop once the pumps do start. + evictGen = session.SetEvictHandler(func() { + cancelOnce() + _ = conn.Close() + }) + + // 8. Send replay frame if there is missed output. + if len(snapshotBytes) > 0 { + frame := make([]byte, 1+8+len(snapshotBytes)) + frame[0] = model.BinReplay + binary.BigEndian.PutUint64(frame[1:9], uint64(snapshotOffset)) + copy(frame[9:], snapshotBytes) + // No connMu needed — pump goroutines not yet started. + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + if err2 := conn.WriteMessage(websocket.BinaryMessage, frame); err2 != nil { + log.Warn("pty ws send replay for session %s: %v", id, err2) + return + } + } + + // 9. Send connected frame. + mode := "pty" + if !session.IsPTY() { + mode = "pipe" + } + if err2 := writeJSON(model.ServerFrame{ + Type: "connected", + SessionID: id, + Mode: mode, + }); err2 != nil { + log.Warn("pty ws send connected for session %s: %v", id, err2) + return + } + + // Upgrade the eviction hook now that the initial writes are done and all subsequent + // writes will be serialized by connMu (pumps + evictClose's TryLock). The full hook + // sends the WSCloseTakenOver close frame best-effort so the client can distinguish an + // intentional handoff from a network drop, then closes the conn and cancels goroutines. + // Generation-tokened: a tearing-down handler never clears a successor's hook. + evictGen = session.SetEvictHandler(func() { + evictClose(model.WSCloseTakenOver, model.WSErrCodeTakenOver) + cancelOnce() + }) + + // 10a. RFC 6455 binary ping goroutine (30 s interval). + safego.Go(func() { ptyPingLoop(conn, &connMu, cancelCh, cancelOnce) }) + + // 10b. Launch stdout pump. + pumpWg.Add(1) + safego.Go(func() { + ptyStreamPump(stdoutR, model.BinStdout, "stdout", id, conn, &connMu, &pumpWg, cancelCh, cancelOnce) + }) + + // 10c. Launch stderr pump (pipe mode only). + if stderrR != nil { + pumpWg.Add(1) + safego.Go(func() { + ptyStreamPump(stderrR, model.BinStderr, "stderr", id, conn, &connMu, &pumpWg, cancelCh, cancelOnce) + }) + } + + // 10d. Exit watcher: waits for the process to exit, then sends exit frame + // and closes the WS connection immediately (unblocks ReadJSON in the read loop). + safego.Go(func() { ptyExitWatcher(session, writeJSON, closeConn, cancelCh, cancelOnce) }) + + // 11. Client read loop. + ptyClientReadLoop(conn, session, id, writeJSON, cancelCh, cancelOnce) +} + +// ptyPingLoop sends periodic WebSocket pings until cancelCh is closed. +func ptyPingLoop(conn *websocket.Conn, connMu *sync.Mutex, cancelCh <-chan struct{}, cancelOnce func()) { + t := time.NewTicker(wsPingInterval) + defer t.Stop() + for { + select { + case <-cancelCh: + return + case <-t.C: + connMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + pingErr := conn.WriteMessage(websocket.PingMessage, nil) + connMu.Unlock() + if pingErr != nil { + cancelOnce() + return + } + } + } +} + +// ptyStreamPump reads raw chunks from r and sends them as binary frames over WS. +func ptyStreamPump(r io.Reader, typeByte byte, name, id string, conn *websocket.Conn, connMu *sync.Mutex, pumpWg *sync.WaitGroup, cancelCh <-chan struct{}, cancelOnce func()) { + defer pumpWg.Done() + const chunkSize = 32 * 1024 + frame := make([]byte, 1+chunkSize) // single allocation for session lifetime + frame[0] = typeByte + for { + select { + case <-cancelCh: + return + default: + } + n, readErr := r.Read(frame[1:]) + if n > 0 { + connMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + writeErr := conn.WriteMessage(websocket.BinaryMessage, frame[:1+n]) + connMu.Unlock() + if writeErr != nil { + log.Warn("pty ws write %s for session %s: %v", name, id, writeErr) + cancelOnce() + return + } + } + if readErr != nil { + // io.EOF or io.ErrClosedPipe when detach() closes the PipeWriter. + return + } + } +} + +// ptyExitWatcher waits for the session process to exit, then sends an exit frame +// and closes the WS connection. +func ptyExitWatcher(session runtime.PTYSession, writeJSON func(any) error, closeConn func(int, string), cancelCh <-chan struct{}, cancelOnce func()) { + doneCh := session.Done() + if doneCh == nil { + return + } + select { + case <-doneCh: + case <-cancelCh: + return + } + exitCode := session.ExitCode() + _ = writeJSON(model.ServerFrame{ + Type: "exit", + ExitCode: &exitCode, + }) + closeConn(websocket.CloseNormalClosure, "process exited") + cancelOnce() +} + +// ptyHandleBinaryMsg processes an incoming binary WebSocket frame from the client. +// Returns true if the connection should be terminated. +func ptyHandleBinaryMsg(session runtime.PTYSession, data []byte, writeJSON func(any) error, cancelOnce func()) bool { + if len(data) == 0 { + return false + } + if data[0] != model.BinStdin { + return false // only stdin expected C→S + } + if _, writeErr := session.WriteStdin(data[1:]); writeErr != nil { + _ = writeJSON(model.ServerFrame{Type: "error", Code: model.WSErrCodeStdinWriteFailed, + Error: writeErr.Error()}) + cancelOnce() + return true + } + return false +} + +// ptyHandleTextMsg processes an incoming text WebSocket frame from the client. +// Returns true if the connection should be terminated. +func ptyHandleTextMsg(session runtime.PTYSession, id string, data []byte, writeJSON func(any) error, cancelOnce func()) bool { + var frame model.ClientFrame + if json.Unmarshal(data, &frame) != nil { + return false + } + switch frame.Type { + case "stdin": + // wscat / debug fallback: plain UTF-8 text, no base64. + if _, writeErr := session.WriteStdin([]byte(frame.Data)); writeErr != nil { + _ = writeJSON(model.ServerFrame{Type: "error", Code: model.WSErrCodeStdinWriteFailed, + Error: writeErr.Error()}) + cancelOnce() + return true + } + case "signal": + session.SendSignal(frame.Signal) + case "resize": + if frame.Cols > 0 && frame.Rows > 0 { + if resErr := session.ResizePTY(uint16(frame.Cols), uint16(frame.Rows)); resErr != nil { + log.Warn("pty resize session %s: %v", id, resErr) + } + } + case "ping": + _ = writeJSON(model.ServerFrame{Type: "pong"}) + default: + _ = writeJSON(model.ServerFrame{Type: "error", Code: model.WSErrCodeInvalidFrame, + Error: fmt.Sprintf("unknown frame type %q", frame.Type)}) + } + return false +} + +// ptyClientReadLoop processes incoming WebSocket messages until the connection closes. +func ptyClientReadLoop(conn *websocket.Conn, session runtime.PTYSession, id string, writeJSON func(any) error, cancelCh <-chan struct{}, cancelOnce func()) { + for { + select { + case <-cancelCh: + return + default: + } + + msgType, data, err := conn.ReadMessage() + if err != nil { + cancelOnce() + return + } + + // Any incoming frame resets the read deadline. + _ = conn.SetReadDeadline(time.Now().Add(wsReadDeadline)) + + switch msgType { + case websocket.BinaryMessage: + if ptyHandleBinaryMsg(session, data, writeJSON, cancelOnce) { + return + } + case websocket.TextMessage: + if ptyHandleTextMsg(session, id, data, writeJSON, cancelOnce) { + return + } + } + } +} + +// writeErrFrame sends a JSON error frame. Safe to call before pump goroutines start. +func writeErrFrame(conn *websocket.Conn, code, message string) { + _ = conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline)) + _ = conn.WriteJSON(model.ServerFrame{ + Type: "error", + Error: message, + Code: code, + }) +} + +// queryInt64 parses a decimal query string value, returning defaultVal on error. +func queryInt64(s string, defaultVal int64) int64 { + if s == "" { + return defaultVal + } + var n int64 + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + return defaultVal + } + return n +} diff --git a/components/execd/pkg/web/controller/pty_ws_test.go b/components/execd/pkg/web/controller/pty_ws_test.go new file mode 100644 index 0000000..bcb7478 --- /dev/null +++ b/components/execd/pkg/web/controller/pty_ws_test.go @@ -0,0 +1,541 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package controller + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// buildPTYRouter assembles a minimal Gin router with only the /pty routes, +// avoiding any import cycle with pkg/web. +func buildPTYRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(gin.Recovery()) + + pty := r.Group("/pty") + { + pty.POST("", func(ctx *gin.Context) { + NewPTYController(ctx).CreatePTYSession() + }) + pty.GET("/:sessionId", func(ctx *gin.Context) { + NewPTYController(ctx).GetPTYSessionStatus() + }) + pty.DELETE("/:sessionId", func(ctx *gin.Context) { + NewPTYController(ctx).DeletePTYSession() + }) + pty.GET("/:sessionId/ws", PTYSessionWebSocket) + } + return r +} + +// newPTYTestServer creates a test HTTP server with fresh codeRunner and PTY routes only. +func newPTYTestServer(t *testing.T) *httptest.Server { + t.Helper() + prev := codeRunner + codeRunner = runtime.NewController("", "") + t.Cleanup(func() { codeRunner = prev }) + return httptest.NewServer(buildPTYRouter()) +} + +// wsDialPTY dials a WebSocket URL; accepts an optional extra query string. +func wsDialPTY(t *testing.T, baseURL, path, query string) *websocket.Conn { + t.Helper() + u := "ws" + strings.TrimPrefix(baseURL+path, "http") + if query != "" { + u += "?" + query + } + conn, resp, err := websocket.DefaultDialer.Dial(u, nil) + if err != nil { + if resp != nil { + t.Fatalf("WS dial %s: %v (HTTP %d)", u, err, resp.StatusCode) + } + t.Fatalf("WS dial %s: %v", u, err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// wsDialExpectHTTP dials and returns the HTTP status without upgrading (for 4xx cases). +func wsDialExpectHTTP(t *testing.T, baseURL, path, query string) int { + t.Helper() + u := "ws" + strings.TrimPrefix(baseURL+path, "http") + if query != "" { + u += "?" + query + } + _, resp, err := websocket.DefaultDialer.Dial(u, nil) + require.Error(t, err, "expected dial to fail with HTTP error") + require.NotNil(t, resp) + return resp.StatusCode +} + +// ptyCreateSession calls POST /pty and returns the session_id. +func ptyCreateSession(t *testing.T, srv *httptest.Server) string { + t.Helper() + resp, err := http.Post(srv.URL+"/pty", "application/json", strings.NewReader(`{}`)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + var r model.CreatePTYSessionResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&r)) + require.NotEmpty(t, r.SessionID) + return r.SessionID +} + +// ptyReadFrame reads the next frame, handling both binary data frames and JSON control frames. +func ptyReadFrame(conn *websocket.Conn, timeout time.Duration) (model.ServerFrame, error) { + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + msgType, raw, err := conn.ReadMessage() + if err != nil { + return model.ServerFrame{}, err + } + if msgType == websocket.TextMessage { + var f model.ServerFrame + return f, json.Unmarshal(raw, &f) + } + // Binary data frame: decode type byte into ServerFrame. + if len(raw) == 0 { + return model.ServerFrame{}, errors.New("empty binary frame") + } + switch raw[0] { + case model.BinStdout: + return model.ServerFrame{Type: "stdout", Data: string(raw[1:])}, nil + case model.BinStderr: + return model.ServerFrame{Type: "stderr", Data: string(raw[1:])}, nil + case model.BinReplay: + if len(raw) < 9 { + return model.ServerFrame{}, fmt.Errorf("replay frame too short: %d bytes", len(raw)) + } + offset := int64(binary.BigEndian.Uint64(raw[1:9])) + return model.ServerFrame{Type: "replay", Data: string(raw[9:]), Offset: offset}, nil + } + return model.ServerFrame{}, fmt.Errorf("unknown binary frame type 0x%02x", raw[0]) +} + +// ptyWaitFrame reads frames until one with the given type is found. +func ptyWaitFrame(t *testing.T, conn *websocket.Conn, wantType string, timeout time.Duration) model.ServerFrame { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + f, err := ptyReadFrame(conn, time.Until(deadline)) + if err != nil { + t.Fatalf("ptyWaitFrame(%q): %v", wantType, err) + } + if f.Type == wantType { + return f + } + } + t.Fatalf("ptyWaitFrame(%q): timed out after %s", wantType, timeout) + return model.ServerFrame{} +} + +// ptyOutputContains reads frames until stdout/stderr/replay contains substr. +func ptyOutputContains(t *testing.T, conn *websocket.Conn, substr string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + f, err := ptyReadFrame(conn, time.Until(deadline)) + if err != nil { + t.Fatalf("ptyOutputContains(%q): read error: %v", substr, err) + } + if f.Type == "stdout" || f.Type == "stderr" || f.Type == "replay" { + if strings.Contains(f.Data, substr) { + return + } + } + } + t.Fatalf("ptyOutputContains(%q): timed out", substr) +} + +// ptyWriteStdin sends a binary stdin frame (production path). +func ptyWriteStdin(t *testing.T, conn *websocket.Conn, text string) { + t.Helper() + frame := make([]byte, 1+len(text)) + frame[0] = model.BinStdin + copy(frame[1:], text) + require.NoError(t, conn.WriteMessage(websocket.BinaryMessage, frame)) +} + +// --- Tests --- + +func TestPTYWS_UnknownSessionReturns404(t *testing.T) { + srv := newPTYTestServer(t) + defer srv.Close() + + code := wsDialExpectHTTP(t, srv.URL, "/pty/nonexistent/ws", "") + require.Equal(t, http.StatusNotFound, code) +} + +func TestPTYWS_AlreadyConnectedReturns409(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn1, "connected", 10*time.Second) + + code := wsDialExpectHTTP(t, srv.URL, "/pty/"+id+"/ws", "") + require.Equal(t, http.StatusConflict, code) +} + +func TestPTYWS_ConnectedFramePTYMode(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + f := ptyWaitFrame(t, conn, "connected", 10*time.Second) + + require.Equal(t, "connected", f.Type) + require.Equal(t, id, f.SessionID) + require.Equal(t, "pty", f.Mode) +} + +func TestPTYWS_StdinForwarding(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn, "connected", 10*time.Second) + + ptyWriteStdin(t, conn, "echo hello_ws\n") + ptyOutputContains(t, conn, "hello_ws", 8*time.Second) +} + +func TestPTYWS_PingPong(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn, "connected", 10*time.Second) + + require.NoError(t, conn.WriteJSON(model.ClientFrame{Type: "ping"})) + f := ptyWaitFrame(t, conn, "pong", 5*time.Second) + require.Equal(t, "pong", f.Type) +} + +func TestPTYWS_ExitFrame(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn, "connected", 10*time.Second) + + ptyWriteStdin(t, conn, "exit 0\n") + + f := ptyWaitFrame(t, conn, "exit", 10*time.Second) + require.Equal(t, "exit", f.Type) + require.NotNil(t, f.ExitCode) + require.Equal(t, 0, *f.ExitCode) +} + +func TestPTYWS_ReplayOnReconnect(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + + // First connection: produce output. + conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn1, "connected", 10*time.Second) + ptyWriteStdin(t, conn1, "echo replay_test\n") + ptyOutputContains(t, conn1, "replay_test", 8*time.Second) + + // Check offset via REST. + resp, err := http.Get(srv.URL + "/pty/" + id) + require.NoError(t, err) + defer resp.Body.Close() + var status model.PTYSessionStatusResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&status)) + require.True(t, status.OutputOffset > 0) + + // Disconnect. + _ = conn1.Close() + time.Sleep(100 * time.Millisecond) + + // Reconnect from offset 0 — should receive a replay frame. + conn2 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "since=0") + + deadline := time.Now().Add(8 * time.Second) + gotReplay := false + for time.Now().Before(deadline) { + f, err2 := ptyReadFrame(conn2, time.Until(deadline)) + if err2 != nil { + break + } + if f.Type == "replay" && strings.Contains(f.Data, "replay_test") { + gotReplay = true + break + } + } + require.True(t, gotReplay, "expected replay frame containing 'replay_test'") +} + +// TestPTYWS_TakeoverEvictsHolder verifies that ?takeover=1 evicts the current +// holder (closing its WS with WSCloseTakenOver) and that the taking-over client +// reattaches to the SAME shell: it replays the prior scrollback and can read a +// shell variable set by the evicted client. +func TestPTYWS_TakeoverEvictsHolder(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + + // Holder connects, sets a shell var, and emits a marker. + conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn1, "connected", 10*time.Second) + ptyWriteStdin(t, conn1, "TAKEOVER_VAR=alive\n") + ptyWriteStdin(t, conn1, "echo holder_marker\n") + ptyOutputContains(t, conn1, "holder_marker", 8*time.Second) + + // A new client takes over. + conn2 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "takeover=1&since=0") + + // 1. The holder's connection is closed with the takeover close code. + var closeErr *websocket.CloseError + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if _, _, err := conn1.ReadMessage(); err != nil { + _ = errors.As(err, &closeErr) + break + } + } + require.NotNil(t, closeErr, "holder should be closed with a WS CloseError after takeover") + require.Equal(t, model.WSCloseTakenOver, closeErr.Code) + + // 2. The taking-over client replays the prior scrollback... + ptyOutputContains(t, conn2, "holder_marker", 8*time.Second) + ptyWaitFrame(t, conn2, "connected", 8*time.Second) + + // 3. ...and it is the SAME shell: the var set on conn1 is still readable. + ptyWriteStdin(t, conn2, "echo SAMESHELL_$TAKEOVER_VAR\n") + ptyOutputContains(t, conn2, "SAMESHELL_alive", 8*time.Second) +} + +// TestPTYWS_TakeoverOnFreeSessionConnects verifies ?takeover=1 is a no-op when the +// session is free: it connects normally (there is no holder to evict). +func TestPTYWS_TakeoverOnFreeSessionConnects(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "takeover=1") + f := ptyWaitFrame(t, conn, "connected", 10*time.Second) + require.Equal(t, id, f.SessionID) +} + +// TestPTYWS_TakeoverRequiresWebSocketUpgrade verifies a plain HTTP GET carrying +// takeover=1 does NOT evict the holder: without a WS handshake it would evict and +// then fail to upgrade, orphaning the session. It must return 409 and leave the +// holder attached and functional. +func TestPTYWS_TakeoverRequiresWebSocketUpgrade(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn1 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn1, "connected", 10*time.Second) + + // Plain HTTP GET (no Upgrade header) with takeover=1 → must be refused, not evict. + resp, err := http.Get(srv.URL + "/pty/" + id + "/ws?takeover=1") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusConflict, resp.StatusCode) + + // The holder is untouched: it still drives the shell. + ptyWriteStdin(t, conn1, "echo still_here\n") + ptyOutputContains(t, conn1, "still_here", 8*time.Second) +} + +// TestPTYWS_ConcurrentTakeovers hammers a session with several simultaneous +// ?takeover=1 reconnects (each with non-empty replay). They must serialize through +// the lock without tripping the race detector — exercising the initial replay/connected +// writes vs. eviction and the cleanup-window paths — and the shell must survive. +func TestPTYWS_ConcurrentTakeovers(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + + // Holder seeds output so every takeover gets a non-empty replay frame. + conn0 := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn0, "connected", 10*time.Second) + ptyWriteStdin(t, conn0, "echo seed_output\n") + ptyOutputContains(t, conn0, "seed_output", 8*time.Second) + + const n = 6 + var wg sync.WaitGroup + var connectedCount int32 + wsURL := "ws" + strings.TrimPrefix(srv.URL+"/pty/"+id+"/ws", "http") + "?takeover=1&since=0" + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + return + } + defer func() { _ = c.Close() }() + deadline := time.Now().Add(6 * time.Second) + for time.Now().Before(deadline) { + f, err := ptyReadFrame(c, time.Until(deadline)) + if err != nil { + return + } + if f.Type == "connected" { + atomic.AddInt32(&connectedCount, 1) + return + } + } + }() + } + wg.Wait() + + // They serialize via the lock; at least one must have attached. + require.GreaterOrEqual(t, atomic.LoadInt32(&connectedCount), int32(1)) + + // The session is still reclaimable and the shell survived the storm. + winner := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "takeover=1&since=0") + ptyWaitFrame(t, winner, "connected", 8*time.Second) + ptyWriteStdin(t, winner, "echo still_alive_after_storm\n") + ptyOutputContains(t, winner, "still_alive_after_storm", 8*time.Second) +} + +func TestPTYWS_ResizeFrame(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "") + ptyWaitFrame(t, conn, "connected", 10*time.Second) + + require.NoError(t, conn.WriteJSON(model.ClientFrame{ + Type: "resize", + Cols: 120, + Rows: 40, + })) + time.Sleep(100 * time.Millisecond) + + ptyWriteStdin(t, conn, "stty size\n") + ptyOutputContains(t, conn, "40 120", 8*time.Second) +} + +func TestPTYWS_PipeModeConnectedFrame(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not found") + } + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + conn := wsDialPTY(t, srv.URL, "/pty/"+id+"/ws", "pty=0") + + f := ptyWaitFrame(t, conn, "connected", 10*time.Second) + require.Equal(t, "pipe", f.Mode) +} + +func TestPTYWS_RESTGetStatus(t *testing.T) { + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + + resp, err := http.Get(srv.URL + "/pty/" + id) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var s model.PTYSessionStatusResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&s)) + require.Equal(t, id, s.SessionID) + require.False(t, s.Running) +} + +func TestPTYWS_RESTDeleteSession(t *testing.T) { + srv := newPTYTestServer(t) + defer srv.Close() + + id := ptyCreateSession(t, srv) + + req, err := http.NewRequest(http.MethodDelete, srv.URL+"/pty/"+id, nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + req2, _ := http.NewRequest(http.MethodDelete, srv.URL+"/pty/"+id, nil) + resp2, err := http.DefaultClient.Do(req2) + require.NoError(t, err) + _ = resp2.Body.Close() + require.Equal(t, http.StatusNotFound, resp2.StatusCode) +} diff --git a/components/execd/pkg/web/controller/sse.go b/components/execd/pkg/web/controller/sse.go new file mode 100644 index 0000000..f4417a3 --- /dev/null +++ b/components/execd/pkg/web/controller/sse.go @@ -0,0 +1,217 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "io" + "net/http" + "time" + + "github.com/alibaba/opensandbox/internal/safego" + "k8s.io/apimachinery/pkg/util/wait" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/runtime" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +var sseHeaders = map[string]string{ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", +} + +// setupSSEResponse is idempotent: once headers are committed, subsequent calls +// no-op. Callers that need the headers up front (e.g. long-running streaming +// endpoints with no early-error path) can call it explicitly. Endpoints that +// may fail synchronously before any event fires should leave header commit to +// the lazy path inside writeSingleEvent so pre-execution errors can return a +// proper JSON body instead of a half-formed text/event-stream response. +func (c *basicController) setupSSEResponse() { + c.sseSetupOnce.Do(func() { + for key, value := range sseHeaders { + c.ctx.Writer.Header().Set(key, value) + } + if flusher, ok := c.ctx.Writer.(http.Flusher); ok { + flusher.Flush() + } + }) +} + +// setServerEventsHandler adapts runtime callbacks to SSE events. +func (c *CodeInterpretingController) setServerEventsHandler(ctx context.Context) runtime.ExecuteResultHook { + return runtime.ExecuteResultHook{ + OnExecuteInit: func(session string) { + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeInit, + Text: session, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteInit", payload, true, event.Summary()) + + safego.Go(func() { c.ping(ctx) }) + }, + OnExecuteResult: func(result map[string]any, count int) { + var mutated map[string]any + if len(result) > 0 { + mutated = make(map[string]any) + for k, v := range result { + switch k { + case "text/plain": + mutated["text"] = v + default: + mutated[k] = v + } + } + } + + if count > 0 { + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeCount, + ExecutionCount: count, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteResult", payload, true, event.Summary()) + } + if len(mutated) > 0 { + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeResult, + Results: mutated, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteResult", payload, true, event.Summary()) + } + }, + OnExecuteComplete: func(executionTime time.Duration) { + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeComplete, + ExecutionTime: executionTime.Milliseconds(), + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteComplete", payload, true, event.Summary()) + }, + OnExecuteError: func(err *execute.ErrorOutput) { + if err == nil { + return + } + + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeError, + Error: err, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteError", payload, true, event.Summary()) + }, + OnExecuteStatus: func(status string) { + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeStatus, + Text: status, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteStatus", payload, true, event.Summary()) + }, + OnExecuteStdout: func(text string) { + if text == "" { + return + } + + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeStdout, + Text: text, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteStdout", payload, true, event.Summary()) + }, + OnExecuteStderr: func(text string) { + if text == "" { + return + } + + event := model.ServerStreamEvent{ + Type: model.StreamEventTypeStderr, + Text: text, + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("OnExecuteStderr", payload, true, event.Summary()) + }, + } +} + +// writeSingleEvent serializes one SSE frame. +func (c *basicController) writeSingleEvent(handler string, data []byte, verbose bool, summary string) { + if c == nil || c.ctx == nil || c.ctx.Writer == nil { + return + } + + select { + case <-c.ctx.Request.Context().Done(): + log.Error("StreamEvent.%s: client disconnected", handler) + return + default: + } + + c.chunkWriter.Lock() + defer c.chunkWriter.Unlock() + // Lazily commit SSE response headers on the first event. This lets the + // surrounding handler return a proper JSON error via RespondError if the + // runtime fails synchronously before any event fires. + c.setupSSEResponse() + defer func() { + if flusher, ok := c.ctx.Writer.(http.Flusher); ok { + flusher.Flush() + } + }() + + payload := append(data, '\n', '\n') + n, err := c.ctx.Writer.Write(payload) + if err == nil && n != len(payload) { + err = io.ErrShortWrite + } + + if err != nil { + log.Error("StreamEvent.%s write data %s error: %v", handler, summary, err) + } else { + if verbose { + log.Info("StreamEvent.%s write data %s", handler, summary) + } + } +} + +// ping periodically keeps the SSE connection alive. +func (c *CodeInterpretingController) ping(ctx context.Context) { + wait.Until(func() { + if c.ctx.Writer == nil { + return + } + event := model.ServerStreamEvent{ + Type: model.StreamEventTypePing, + Text: "pong", + Timestamp: time.Now().UnixMilli(), + } + payload := event.ToJSON() + c.writeSingleEvent("Ping", payload, false, event.Summary()) + }, 3*time.Second, ctx.Done()) +} diff --git a/components/execd/pkg/web/controller/syscall_linux.go b/components/execd/pkg/web/controller/syscall_linux.go new file mode 100644 index 0000000..5ce6177 --- /dev/null +++ b/components/execd/pkg/web/controller/syscall_linux.go @@ -0,0 +1,33 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux +// +build linux + +package controller + +import ( + "os" + "syscall" + "time" +) + +func getFileCreateTime(fileInfo os.FileInfo) time.Time { + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok || stat == nil { + return fileInfo.ModTime() + } + + return time.Unix(stat.Ctim.Sec, stat.Ctim.Nsec) +} diff --git a/components/execd/pkg/web/controller/syscall_others.go b/components/execd/pkg/web/controller/syscall_others.go new file mode 100644 index 0000000..f01885d --- /dev/null +++ b/components/execd/pkg/web/controller/syscall_others.go @@ -0,0 +1,27 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux +// +build !linux + +package controller + +import ( + "os" + "time" +) + +func getFileCreateTime(_ os.FileInfo) time.Time { + return time.Now() +} diff --git a/components/execd/pkg/web/controller/telemetry_helpers.go b/components/execd/pkg/web/controller/telemetry_helpers.go new file mode 100644 index 0000000..0735a8f --- /dev/null +++ b/components/execd/pkg/web/controller/telemetry_helpers.go @@ -0,0 +1,51 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "time" + + "github.com/alibaba/opensandbox/execd/pkg/telemetry" +) + +type filesystemMetricRecorder struct { + start time.Time + operation string + success bool +} + +func beginFilesystemMetric(operation string) *filesystemMetricRecorder { + return &filesystemMetricRecorder{ + start: time.Now(), + operation: operation, + } +} + +func (r *filesystemMetricRecorder) MarkSuccess() { + r.success = true +} + +func (r *filesystemMetricRecorder) Finish(ctrl *basicController) { + result := "failure" + if r.success { + result = "success" + } + telemetry.RecordFilesystemOperation( + ctrl.ctx.Request.Context(), + r.operation, + result, + float64(time.Since(r.start))/float64(time.Millisecond), + ) +} diff --git a/components/execd/pkg/web/controller/test_helpers.go b/components/execd/pkg/web/controller/test_helpers.go new file mode 100644 index 0000000..f931dfd --- /dev/null +++ b/components/execd/pkg/web/controller/test_helpers.go @@ -0,0 +1,32 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "bytes" + "net/http/httptest" + + "github.com/gin-gonic/gin" +) + +// nolint:unused +func newTestContext(method, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + ctx.Request = req + return ctx, w +} diff --git a/components/execd/pkg/web/controller/utils.go b/components/execd/pkg/web/controller/utils.go new file mode 100644 index 0000000..2986d86 --- /dev/null +++ b/components/execd/pkg/web/controller/utils.go @@ -0,0 +1,353 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows +// +build !windows + +package controller + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +func DeleteFile(filePath string) error { + absPath, err := pathutil.ExpandAbsPath(filePath) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + fileInfo, err := os.Stat(absPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + + if fileInfo.IsDir() { + return fmt.Errorf("path is a directory: %s", filePath) + } + + if err := os.Remove(absPath); err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + + return nil +} + +func ChmodFile(file string, perms model.Permission) error { + abs, err := pathutil.ExpandAbsPath(file) + if err != nil { + return err + } + + if perms.Mode != 0 { + mode, err := strconv.ParseUint(strconv.Itoa(perms.Mode), 8, 32) + if err != nil { + return err + } + err = os.Chmod(abs, os.FileMode(mode)) + if err != nil { + return err + } + } + return SetFileOwnership(abs, perms.Owner, perms.Group) +} + +func SetFileOwnership(absPath string, owner string, group string) error { + if owner == "" && group == "" { + return nil + } + + uid := -1 + if owner != "" { + userInfo, err := user.Lookup(owner) + if err != nil { + return fmt.Errorf("failed to lookup user %s: %w", owner, err) + } + uid, err = strconv.Atoi(userInfo.Uid) + if err != nil { + return fmt.Errorf("failed to convert uid for user %s: %w", owner, err) + } + } + + gid := -1 + if group != "" { + groupInfo, err := user.LookupGroup(group) + if err != nil { + return fmt.Errorf("failed to lookup group %s: %w", group, err) + } + gid, err = strconv.Atoi(groupInfo.Gid) + if err != nil { + return fmt.Errorf("failed to convert gid for group %s: %w", group, err) + } + } + + if err := os.Chown(absPath, uid, gid); err != nil { + return fmt.Errorf("failed to set owner/group for %s: %w", absPath, err) + } + + return nil +} + +func RenameFile(item model.RenameFileItem) error { + srcPath, err := pathutil.ExpandAbsPath(item.Src) + if err != nil { + return fmt.Errorf("invalid source path: %w", err) + } + + dstPath, err := pathutil.ExpandAbsPath(item.Dest) + if err != nil { + return fmt.Errorf("invalid destination path: %w", err) + } + + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + return fmt.Errorf("source path not found: %s: %w", item.Src, err) + } + + dstDir := filepath.Dir(dstPath) + + if err := os.MkdirAll(dstDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + if _, err := os.Stat(dstPath); err == nil { + return fmt.Errorf("destination path already exists: %s", item.Dest) + } + + if err := os.Rename(srcPath, dstPath); err != nil { + return fmt.Errorf("failed to rename file: %w", err) + } + + return nil +} + +// MkdirAllWithOwnership creates targetDir and any missing parents, then applies +// owner/group only to the directories that were actually created (not pre-existing ones). +func MkdirAllWithOwnership(targetDir string, dirPerm os.FileMode, owner, group string) error { + // Walk up to find the first directory that needs to be created. + firstNew := "" + cur := targetDir + for { + if _, err := os.Stat(cur); err == nil { + break + } + firstNew = cur + parent := filepath.Dir(cur) + if parent == cur { + break + } + cur = parent + } + + if err := os.MkdirAll(targetDir, dirPerm); err != nil { + return err + } + + if firstNew == "" || (owner == "" && group == "") { + return nil + } + + // Apply ownership to every newly created directory from firstNew down to targetDir. + rel, err := filepath.Rel(firstNew, targetDir) + if err != nil { + return err + } + parts := strings.Split(rel, string(filepath.Separator)) + cur = firstNew + if err := SetFileOwnership(cur, owner, group); err != nil { + return err + } + for _, p := range parts { + if p == "." { + continue + } + cur = filepath.Join(cur, p) + if err := SetFileOwnership(cur, owner, group); err != nil { + return err + } + } + return nil +} + +func MakeDir(dir string, perm model.Permission) error { + abs, err := pathutil.ExpandAbsPath(dir) + if err != nil { + return err + } + + _, statErr := os.Stat(abs) + existed := statErr == nil + + if err := MkdirAllWithOwnership(abs, os.ModePerm, perm.Owner, perm.Group); err != nil { + return err + } + + if !existed && perm.Mode != 0 { + mode, err := strconv.ParseUint(strconv.Itoa(perm.Mode), 8, 32) + if err != nil { + return err + } + return os.Chmod(abs, os.FileMode(mode)) + } + return nil +} + +func fileType(fileInfo os.FileInfo) string { + mode := fileInfo.Mode() + if mode&os.ModeSymlink != 0 { + return "symlink" + } + if fileInfo.IsDir() { + return "directory" + } + if mode.IsRegular() { + return "file" + } + return "other" +} + +func buildFileInfo(absPath string, fileInfo os.FileInfo) (model.FileInfo, error) { + stat := fileInfo.Sys().(*syscall.Stat_t) + + owner := strconv.FormatUint(uint64(stat.Uid), 10) + if ownerUser, err := user.LookupId(owner); err == nil { + owner = ownerUser.Username + } + + group := strconv.FormatUint(uint64(stat.Gid), 10) + if groupInfo, err := user.LookupGroupId(group); err == nil { + group = groupInfo.Name + } + + mode := strconv.FormatInt(int64(fileInfo.Mode().Perm()), 8) + + return model.FileInfo{ + Path: absPath, + Type: fileType(fileInfo), + Size: fileInfo.Size(), + ModifiedAt: fileInfo.ModTime(), + CreatedAt: getFileCreateTime(fileInfo), + Permission: model.Permission{ + Owner: owner, + Group: group, + Mode: func() int { i, _ := strconv.Atoi(mode); return i }(), + }, + }, nil +} + +func GetFileInfo(filePath string) (model.FileInfo, error) { + absPath, err := pathutil.ExpandAbsPath(filePath) + if err != nil { + return model.FileInfo{}, fmt.Errorf("invalid path %s: %w", filePath, err) + } + + fileInfo, err := os.Lstat(absPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return model.FileInfo{}, fmt.Errorf("file not found: %s: %w", filePath, err) + } + return model.FileInfo{}, fmt.Errorf("error accessing file %s: %w", filePath, err) + } + + return buildFileInfo(absPath, fileInfo) +} + +func SearchFileMetadata(metadata map[string]model.FileMetadata, filePath string) (string, model.FileMetadata, bool) { + base := filepath.Base(filePath) + for path, info := range metadata { + if filepath.Base(path) == base { + return path, info, true + } + } + + return "", model.FileMetadata{}, false +} + +type httpRange struct { + start, length int64 +} + +func ParseRange(s string, size int64) ([]httpRange, error) { + if !strings.HasPrefix(s, "bytes=") { + return nil, errors.New("invalid range") + } + + ranges := strings.Split(s[6:], ",") + result := make([]httpRange, 0, len(ranges)) + + for _, ra := range ranges { + ra = strings.TrimSpace(ra) + if ra == "" { + continue + } + i := strings.Index(ra, "-") + if i < 0 { + return nil, errors.New("invalid range") + } + start, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:]) + var r httpRange + + if start == "" { + // suffix-length + n, err := strconv.ParseInt(end, 10, 64) + if err != nil || n < 0 { + return nil, errors.New("invalid range") + } + if n > size { + n = size + } + r.start = size - n + r.length = size - r.start + } else { + // start-end + i, err := strconv.ParseInt(start, 10, 64) + if err != nil || i < 0 { + return nil, errors.New("invalid range") + } + if end == "" { + // start- + r.start = i + r.length = size - i + } else { + // start-end + j, err := strconv.ParseInt(end, 10, 64) + if err != nil || j < i { + return nil, errors.New("invalid range") + } + r.start = i + r.length = j - i + 1 + } + } + if r.start >= size { + continue + } + if r.start+r.length > size { + r.length = size - r.start + } + result = append(result, r) + } + return result, nil +} diff --git a/components/execd/pkg/web/controller/utils_test.go b/components/execd/pkg/web/controller/utils_test.go new file mode 100644 index 0000000..e841225 --- /dev/null +++ b/components/execd/pkg/web/controller/utils_test.go @@ -0,0 +1,219 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/alibaba/opensandbox/execd/pkg/web/model" + "github.com/stretchr/testify/require" +) + +func TestDeleteFile(t *testing.T) { + tmp := t.TempDir() + file := filepath.Join(tmp, "sample.txt") + require.NoError(t, os.WriteFile(file, []byte("hello"), 0o644)) + + require.NoError(t, DeleteFile(file)) + _, err := os.Stat(file) + require.True(t, os.IsNotExist(err), "expected file removed, got err=%v", err) + + // removing a non-existent file should be a no-op + require.NoError(t, DeleteFile(file), "expected no error deleting missing file") +} + +func TestRenameFile(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src.txt") + require.NoError(t, os.WriteFile(src, []byte("data"), 0o644)) + + dst := filepath.Join(tmp, "nested", "renamed.txt") + require.NoError(t, RenameFile(model.RenameFileItem{Src: src, Dest: dst})) + + _, err := os.Stat(dst) + require.NoError(t, err) + _, err = os.Stat(src) + require.True(t, os.IsNotExist(err), "expected source removed, got err=%v", err) + + // destination exists -> expect error + require.NoError(t, os.WriteFile(src, []byte("data"), 0o644)) + require.Error(t, RenameFile(model.RenameFileItem{Src: src, Dest: dst}), "expected error when destination already exists") +} + +func TestMakeDir_PreExistingDir(t *testing.T) { + tmp := t.TempDir() + existing := filepath.Join(tmp, "existing") + require.NoError(t, os.Mkdir(existing, 0o755)) + + origInfo, err := os.Stat(existing) + require.NoError(t, err) + origMode := origInfo.Mode().Perm() + + err = MakeDir(existing, model.Permission{Mode: 700}) + require.NoError(t, err, "MakeDir on pre-existing dir should not fail") + + afterInfo, err := os.Stat(existing) + require.NoError(t, err) + require.Equal(t, origMode, afterInfo.Mode().Perm(), "permissions of pre-existing dir should be unchanged") +} + +func TestMakeDir_NewDir(t *testing.T) { + tmp := t.TempDir() + newDir := filepath.Join(tmp, "brand-new") + + err := MakeDir(newDir, model.Permission{Mode: 755}) + require.NoError(t, err) + + info, err := os.Stat(newDir) + require.NoError(t, err) + require.True(t, info.IsDir()) + if runtime.GOOS != "windows" { + require.Equal(t, os.FileMode(0o755), info.Mode().Perm()) + } +} + +func TestMkdirAllWithOwnership_NewNestedDirs(t *testing.T) { + tmp := t.TempDir() + nested := filepath.Join(tmp, "a", "b", "c") + + err := MkdirAllWithOwnership(nested, 0o755, "", "") + require.NoError(t, err) + + for _, seg := range []string{"a", "a/b", "a/b/c"} { + info, err := os.Stat(filepath.Join(tmp, seg)) + require.NoError(t, err) + require.True(t, info.IsDir()) + } +} + +func TestMkdirAllWithOwnership_PreExistingParent(t *testing.T) { + tmp := t.TempDir() + existing := filepath.Join(tmp, "existing") + require.NoError(t, os.Mkdir(existing, 0o755)) + + origInfo, err := os.Stat(existing) + require.NoError(t, err) + origMode := origInfo.Mode().Perm() + + nested := filepath.Join(existing, "new-child", "deep") + err = MkdirAllWithOwnership(nested, 0o755, "", "") + require.NoError(t, err) + + afterInfo, err := os.Stat(existing) + require.NoError(t, err) + require.Equal(t, origMode, afterInfo.Mode().Perm(), "pre-existing dir should be unchanged") + + info, err := os.Stat(nested) + require.NoError(t, err) + require.True(t, info.IsDir()) +} + +func TestMkdirAllWithOwnership_AllExist(t *testing.T) { + tmp := t.TempDir() + existing := filepath.Join(tmp, "already") + require.NoError(t, os.MkdirAll(existing, 0o755)) + + err := MkdirAllWithOwnership(existing, 0o755, "", "") + require.NoError(t, err, "all dirs exist — should be no-op") +} + +func TestSetFileOwnership_EmptyOwnerGroup(t *testing.T) { + tmp := t.TempDir() + file := filepath.Join(tmp, "test.txt") + require.NoError(t, os.WriteFile(file, []byte("data"), 0o644)) + + err := SetFileOwnership(file, "", "") + require.NoError(t, err, "empty owner and group should be a no-op") +} + +func TestSetFileOwnership_MissingFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("SetFileOwnership is a no-op on Windows") + } + err := SetFileOwnership("/nonexistent/path/file.txt", "root", "") + require.Error(t, err, "chown on missing file should return error") +} + +func TestSetFileOwnership_InvalidOwner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("SetFileOwnership is a no-op on Windows") + } + tmp := t.TempDir() + file := filepath.Join(tmp, "test.txt") + require.NoError(t, os.WriteFile(file, []byte("data"), 0o644)) + + err := SetFileOwnership(file, "nonexistent_user_xyz", "") + require.Error(t, err, "invalid owner should return error") +} + +func TestSearchFileMetadata(t *testing.T) { + metadata := map[string]model.FileMetadata{ + "/tmp/a/notes.txt": {Path: "/tmp/a/notes.txt"}, + "/tmp/b/readme.md": {Path: "/tmp/b/readme.md"}, + } + + path, info, ok := SearchFileMetadata(metadata, "/any/notes.txt") + require.True(t, ok, "expected metadata entry") + require.Equal(t, "/tmp/a/notes.txt", path) + require.Equal(t, "/tmp/a/notes.txt", info.Path) + + _, _, ok = SearchFileMetadata(metadata, "/foo/unknown.txt") + require.False(t, ok, "expected no match") +} + +func TestParseRange(t *testing.T) { + tests := []struct { + name string + header string + size int64 + want []httpRange + expectErr bool + }{ + { + name: "start-end", + header: "bytes=0-9", + size: 20, + want: []httpRange{{start: 0, length: 10}}, + }, + { + name: "suffix", + header: "bytes=-5", + size: 10, + want: []httpRange{{start: 5, length: 5}}, + }, + { + name: "invalid", + header: "bytes=foo", + size: 10, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseRange(tt.header, tt.size) + if tt.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.True(t, reflect.DeepEqual(got, tt.want), "got %+v want %+v", got, tt.want) + }) + } +} diff --git a/components/execd/pkg/web/controller/utils_windows.go b/components/execd/pkg/web/controller/utils_windows.go new file mode 100644 index 0000000..57c1c75 --- /dev/null +++ b/components/execd/pkg/web/controller/utils_windows.go @@ -0,0 +1,317 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package controller + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/util/pathutil" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +func DeleteFile(filePath string) error { + absPath, err := pathutil.ExpandAbsPath(filePath) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + fileInfo, err := os.Stat(absPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + + if fileInfo.IsDir() { + return fmt.Errorf("path is a directory: %s", filePath) + } + + if err := os.Remove(absPath); err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + + return nil +} + +func ChmodFile(file string, perms model.Permission) error { + abs, err := pathutil.ExpandAbsPath(file) + if err != nil { + return err + } + + if perms.Mode != 0 { + mode, err := strconv.ParseUint(strconv.Itoa(perms.Mode), 8, 32) + if err != nil { + return err + } + err = os.Chmod(abs, os.FileMode(mode)) + if err != nil { + return err + } + } + return SetFileOwnership(abs, perms.Owner, perms.Group) +} + +// SetFileOwnership is a placeholder on Windows where POSIX ownership is not supported. +func SetFileOwnership(_ string, _ string, _ string) error { + // TODO: add Windows ACL support if needed. + return nil +} + +func RenameFile(item model.RenameFileItem) error { + srcPath, err := pathutil.ExpandAbsPath(item.Src) + if err != nil { + return fmt.Errorf("invalid source path: %w", err) + } + + dstPath, err := pathutil.ExpandAbsPath(item.Dest) + if err != nil { + return fmt.Errorf("invalid destination path: %w", err) + } + + if _, err := os.Stat(srcPath); os.IsNotExist(err) { + return fmt.Errorf("source path not found: %s: %w", item.Src, err) + } + + dstDir := filepath.Dir(dstPath) + + if err := os.MkdirAll(dstDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + if _, err := os.Stat(dstPath); err == nil { + return fmt.Errorf("destination path already exists: %s", item.Dest) + } + + if err := os.Rename(srcPath, dstPath); err != nil { + return fmt.Errorf("failed to rename file: %w", err) + } + + return nil +} + +// MkdirAllWithOwnership creates targetDir and any missing parents, then applies +// owner/group only to the directories that were actually created (not pre-existing ones). +func MkdirAllWithOwnership(targetDir string, dirPerm os.FileMode, owner, group string) error { + firstNew := "" + cur := targetDir + for { + if _, err := os.Stat(cur); err == nil { + break + } + firstNew = cur + parent := filepath.Dir(cur) + if parent == cur { + break + } + cur = parent + } + + if err := os.MkdirAll(targetDir, dirPerm); err != nil { + return err + } + + if firstNew == "" || (owner == "" && group == "") { + return nil + } + + rel, err := filepath.Rel(firstNew, targetDir) + if err != nil { + return err + } + parts := strings.Split(rel, string(filepath.Separator)) + cur = firstNew + if err := SetFileOwnership(cur, owner, group); err != nil { + return err + } + for _, p := range parts { + if p == "." { + continue + } + cur = filepath.Join(cur, p) + if err := SetFileOwnership(cur, owner, group); err != nil { + return err + } + } + return nil +} + +func MakeDir(dir string, perm model.Permission) error { + abs, err := pathutil.ExpandAbsPath(dir) + if err != nil { + return err + } + + _, statErr := os.Stat(abs) + existed := statErr == nil + + if err := MkdirAllWithOwnership(abs, os.ModePerm, perm.Owner, perm.Group); err != nil { + return err + } + + if !existed && perm.Mode != 0 { + mode, err := strconv.ParseUint(strconv.Itoa(perm.Mode), 8, 32) + if err != nil { + return err + } + return os.Chmod(abs, os.FileMode(mode)) + } + return nil +} + +func fileType(fileInfo os.FileInfo) string { + mode := fileInfo.Mode() + if mode&os.ModeSymlink != 0 { + return "symlink" + } + if fileInfo.IsDir() { + return "directory" + } + if mode.IsRegular() { + return "file" + } + return "other" +} + +func buildFileInfo(absPath string, fileInfo os.FileInfo) (model.FileInfo, error) { + createdAt := getFileCreateTime(fileInfo) + if data, ok := fileInfo.Sys().(*syscall.Win32FileAttributeData); ok && data != nil { + createdAt = time.Unix(0, data.CreationTime.Nanoseconds()) + } + + mode := strconv.FormatInt(int64(fileInfo.Mode().Perm()), 8) + + return model.FileInfo{ + Path: absPath, + Type: fileType(fileInfo), + Size: fileInfo.Size(), + ModifiedAt: fileInfo.ModTime(), + CreatedAt: createdAt, + Permission: model.Permission{ + Owner: "", + Group: "", + Mode: func() int { + i, _ := strconv.Atoi(mode) + return i + }(), + }, + }, nil +} + +func GetFileInfo(filePath string) (model.FileInfo, error) { + absPath, err := pathutil.ExpandAbsPath(filePath) + if err != nil { + return model.FileInfo{}, fmt.Errorf("invalid path %s: %w", filePath, err) + } + + fileInfo, err := os.Lstat(absPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return model.FileInfo{}, fmt.Errorf("file not found: %s: %w", filePath, err) + } + return model.FileInfo{}, fmt.Errorf("error accessing file %s: %w", filePath, err) + } + + return buildFileInfo(absPath, fileInfo) +} + +func SearchFileMetadata(metadata map[string]model.FileMetadata, filePath string) (string, model.FileMetadata, bool) { + base := filepath.Base(filePath) + for path, info := range metadata { + if filepath.Base(path) == base { + return path, info, true + } + } + + return "", model.FileMetadata{}, false +} + +type httpRange struct { + start, length int64 +} + +func ParseRange(s string, size int64) ([]httpRange, error) { + if !strings.HasPrefix(s, "bytes=") { + return nil, errors.New("invalid range") + } + + ranges := strings.Split(s[6:], ",") + result := make([]httpRange, 0, len(ranges)) + + for _, ra := range ranges { + ra = strings.TrimSpace(ra) + if ra == "" { + continue + } + i := strings.Index(ra, "-") + if i < 0 { + return nil, errors.New("invalid range") + } + start, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:]) + var r httpRange + + if start == "" { + // suffix-length + n, err := strconv.ParseInt(end, 10, 64) + if err != nil || n < 0 { + return nil, errors.New("invalid range") + } + if n > size { + n = size + } + r.start = size - n + r.length = size - r.start + } else { + // start-end + i, err := strconv.ParseInt(start, 10, 64) + if err != nil || i < 0 { + return nil, errors.New("invalid range") + } + if end == "" { + // start- + r.start = i + r.length = size - i + } else { + // start-end + j, err := strconv.ParseInt(end, 10, 64) + if err != nil || j < i { + return nil, errors.New("invalid range") + } + r.start = i + r.length = j - i + 1 + } + } + if r.start >= size { + continue + } + if r.start+r.length > size { + r.length = size - r.start + } + result = append(result, r) + } + return result, nil +} diff --git a/components/execd/pkg/web/model/codeinterpreting.go b/components/execd/pkg/web/model/codeinterpreting.go new file mode 100644 index 0000000..c45382a --- /dev/null +++ b/components/execd/pkg/web/model/codeinterpreting.go @@ -0,0 +1,133 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/go-playground/validator/v10" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// RunCodeRequest represents a code execution request. +type RunCodeRequest struct { + Context CodeContext `json:"context,omitempty"` + Code string `json:"code" validate:"required"` +} + +func (r *RunCodeRequest) Validate() error { + validate := validator.New() + return validate.Struct(r) +} + +// CodeContext tracks session metadata. +type CodeContext struct { + ID string `json:"id,omitempty"` + CodeContextRequest `json:",inline"` +} + +type CodeContextRequest struct { + Language string `json:"language,omitempty"` + Cwd string `json:"cwd,omitempty"` +} + +// RunCommandRequest represents a shell command execution request. +type RunCommandRequest struct { + Command string `json:"command" validate:"required"` + Cwd string `json:"cwd,omitempty"` + Background bool `json:"background,omitempty"` + // TimeoutMs caps execution duration; 0 uses server default. + TimeoutMs int64 `json:"timeout,omitempty" validate:"omitempty,gte=1"` + + Uid *uint32 `json:"uid,omitempty"` + Gid *uint32 `json:"gid,omitempty"` + Envs map[string]string `json:"envs,omitempty"` +} + +func (r *RunCommandRequest) Validate() error { + validate := validator.New() + if err := validate.Struct(r); err != nil { + return err + } + if r.Gid != nil && r.Uid == nil { + return errors.New("uid is required when gid is provided") + } + return runtime.ValidateWorkingDir(r.Cwd) +} + +type ServerStreamEventType string + +const ( + StreamEventTypeInit ServerStreamEventType = "init" + StreamEventTypeStatus ServerStreamEventType = "status" + StreamEventTypeError ServerStreamEventType = "error" + StreamEventTypeStdout ServerStreamEventType = "stdout" + StreamEventTypeStderr ServerStreamEventType = "stderr" + StreamEventTypeResult ServerStreamEventType = "result" + StreamEventTypeComplete ServerStreamEventType = "execution_complete" + StreamEventTypeCount ServerStreamEventType = "execution_count" + StreamEventTypePing ServerStreamEventType = "ping" +) + +// ServerStreamEvent is emitted to clients over SSE. +type ServerStreamEvent struct { + Type ServerStreamEventType `json:"type,omitempty"` + Text string `json:"text,omitempty"` + ExecutionCount int `json:"execution_count,omitempty"` + ExecutionTime int64 `json:"execution_time,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Results map[string]any `json:"results,omitempty"` + Error *execute.ErrorOutput `json:"error,omitempty"` +} + +// ToJSON serializes the event for streaming. +func (s ServerStreamEvent) ToJSON() []byte { + bytes, _ := json.Marshal(s) + return bytes +} + +// Summary renders a lightweight, log-friendly string without JSON. +func (s ServerStreamEvent) Summary() string { + parts := []string{fmt.Sprintf("type=%s", s.Type)} + if s.Text != "" { + parts = append(parts, fmt.Sprintf("text=%s", truncateString(s.Text, 100))) + } + if s.ExecutionTime > 0 { + parts = append(parts, fmt.Sprintf("elapsed_ms=%d", s.ExecutionTime)) + } + if len(s.Results) > 0 { + parts = append(parts, fmt.Sprintf("results=%d", len(s.Results))) + } + if s.Error != nil { + errLabel := s.Error.EName + if errLabel == "" { + errLabel = "error" + } + parts = append(parts, fmt.Sprintf("error=%s: %s", errLabel, truncateString(s.Error.EValue, 80))) + } + return strings.Join(parts, " ") +} + +func truncateString(value string, maxCount int) string { + if maxCount <= 0 || len(value) <= maxCount { + return value + } + return value[:maxCount] + "..." +} diff --git a/components/execd/pkg/web/model/codeinterpreting_test.go b/components/execd/pkg/web/model/codeinterpreting_test.go new file mode 100644 index 0000000..f90536b --- /dev/null +++ b/components/execd/pkg/web/model/codeinterpreting_test.go @@ -0,0 +1,134 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" + "github.com/stretchr/testify/require" +) + +func TestRunCodeRequestValidate(t *testing.T) { + req := RunCodeRequest{ + Code: "print('hi')", + } + require.NoError(t, req.Validate()) + + req.Code = "" + require.Error(t, req.Validate(), "expected validation error when code is empty") +} + +func TestRunCommandRequestValidate(t *testing.T) { + req := RunCommandRequest{Command: "ls"} + require.NoError(t, req.Validate(), "expected command validation success") + + req.TimeoutMs = -100 + require.Error(t, req.Validate(), "expected validation error when timeout is negative") + + req.TimeoutMs = 0 + req.Command = "ls" + require.NoError(t, req.Validate(), "expected success when timeout is omitted/zero") + + req.TimeoutMs = 10 + req.Command = "" + require.Error(t, req.Validate(), "expected validation error when command is empty") +} + +func TestRunCommandRequestValidateCwd(t *testing.T) { + tmp := t.TempDir() + req := RunCommandRequest{Command: "ls", Cwd: tmp} + require.NoError(t, req.Validate()) + + req.Cwd = filepath.Join(tmp, "missing-subdir") + err := req.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "working directory") +} + +func ptr32(v uint32) *uint32 { return &v } + +func TestRunCommandRequestValidateUidGid(t *testing.T) { + // uid-only: valid + req := RunCommandRequest{Command: "id", Uid: ptr32(1000)} + require.NoError(t, req.Validate(), "expected success with uid only") + + // uid + gid: valid + req = RunCommandRequest{Command: "id", Uid: ptr32(1000), Gid: ptr32(1000)} + require.NoError(t, req.Validate(), "expected success with uid and gid") + + // gid-only: must be rejected + req = RunCommandRequest{Command: "id", Gid: ptr32(1000)} + require.Error(t, req.Validate(), "expected validation error when gid is set without uid") +} + +func TestServerStreamEventToJSON(t *testing.T) { + event := ServerStreamEvent{ + Type: StreamEventTypeStdout, + Text: "hello", + ExecutionCount: 3, + } + + data := event.ToJSON() + var decoded ServerStreamEvent + require.NoError(t, json.Unmarshal(data, &decoded)) + require.Equal(t, event.Type, decoded.Type) + require.Equal(t, event.Text, decoded.Text) + require.Equal(t, event.ExecutionCount, decoded.ExecutionCount) +} + +func TestServerStreamEventSummary(t *testing.T) { + longText := strings.Repeat("a", 120) + tests := []struct { + name string + event ServerStreamEvent + contains []string + }{ + { + name: "basic stdout", + event: ServerStreamEvent{ + Type: StreamEventTypeStdout, + Text: "hello", + ExecutionCount: 2, + }, + contains: []string{"type=stdout", "text=hello"}, + }, + { + name: "truncated text and error", + event: ServerStreamEvent{ + Type: StreamEventTypeError, + Text: longText, + Error: &execute.ErrorOutput{EName: "ValueError", EValue: "boom"}, + }, + contains: []string{ + "type=error", + "text=" + strings.Repeat("a", 100) + "...", + "error=ValueError: boom", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + summary := tt.event.Summary() + for _, want := range tt.contains { + require.Containsf(t, summary, want, "summary missing %q", want) + } + }) + } +} diff --git a/components/execd/pkg/web/model/command.go b/components/execd/pkg/web/model/command.go new file mode 100644 index 0000000..0d35aa8 --- /dev/null +++ b/components/execd/pkg/web/model/command.go @@ -0,0 +1,28 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "time" + +// CommandStatusResponse represents command status for REST APIs. +type CommandStatusResponse struct { + ID string `json:"id"` + Content string `json:"content,omitempty"` + Running bool `json:"running"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} diff --git a/components/execd/pkg/web/model/error.go b/components/execd/pkg/web/model/error.go new file mode 100644 index 0000000..595e4a0 --- /dev/null +++ b/components/execd/pkg/web/model/error.go @@ -0,0 +1,37 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +type ErrorCode string + +const ( + ErrorCodeInvalidRequest ErrorCode = "INVALID_REQUEST_BODY" + ErrorCodeMissingQuery ErrorCode = "MISSING_QUERY" + ErrorCodeRuntimeError ErrorCode = "RUNTIME_ERROR" + ErrorCodeInvalidFile ErrorCode = "INVALID_FILE" + ErrorCodeInvalidFileContent ErrorCode = "INVALID_FILE_CONTENT" + ErrorCodeInvalidFileMetadata ErrorCode = "INVALID_FILE_METADATA" + ErrorCodeFileNotFound ErrorCode = "FILE_NOT_FOUND" + ErrorCodeUnknown ErrorCode = "UNKNOWN" + ErrorCodeContextNotFound ErrorCode = "CONTEXT_NOT_FOUND" + ErrorCodeNotSupported ErrorCode = "NOT_SUPPORTED" + ErrorCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE" + ErrorCodeSessionNotFound ErrorCode = "SESSION_NOT_FOUND" +) + +type ErrorResponse struct { + Code ErrorCode `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/components/execd/pkg/web/model/filesystem.go b/components/execd/pkg/web/model/filesystem.go new file mode 100644 index 0000000..f7ef3c4 --- /dev/null +++ b/components/execd/pkg/web/model/filesystem.go @@ -0,0 +1,56 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "time" + +// FileInfo represents file metadata including path and permissions +type FileInfo struct { + Path string `json:"path,omitempty"` + Type string `json:"type,omitempty"` + Size int64 `json:"size"` + ModifiedAt time.Time `json:"modified_at,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Permission `json:",inline"` +} + +type FileMetadata struct { + Path string `json:"path,omitempty"` + Permission `json:",inline"` +} + +// Permission represents file ownership and mode +type Permission struct { + Owner string `json:"owner"` + Group string `json:"group"` + Mode int `json:"mode"` +} + +// RenameFileItem represents a file rename operation +type RenameFileItem struct { + Src string `json:"src,omitempty"` + Dest string `json:"dest,omitempty"` +} + +// ReplaceFileContentItem represents a content replacement operation +type ReplaceFileContentItem struct { + Old string `json:"old,omitempty"` + New string `json:"new,omitempty"` +} + +// ReplaceFileContentResult represents the result of a content replacement on a single file +type ReplaceFileContentResult struct { + ReplacedCount int `json:"replacedCount"` +} diff --git a/components/execd/pkg/web/model/header.go b/components/execd/pkg/web/model/header.go new file mode 100644 index 0000000..03f7c27 --- /dev/null +++ b/components/execd/pkg/web/model/header.go @@ -0,0 +1,20 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +const ( + // ApiAccessTokenHeader carries the auth token. + ApiAccessTokenHeader = "X-EXECD-ACCESS-TOKEN" +) diff --git a/components/execd/pkg/web/model/isolated_session.go b/components/execd/pkg/web/model/isolated_session.go new file mode 100644 index 0000000..bcce7c1 --- /dev/null +++ b/components/execd/pkg/web/model/isolated_session.go @@ -0,0 +1,166 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "fmt" + "strings" + "time" + + "github.com/go-playground/validator/v10" +) + +// Workspace mode values. +const ( + WorkspaceModeRW = "rw" + WorkspaceModeOverlay = "overlay" + WorkspaceModeRO = "ro" +) + +// Create + +// CreateIsolatedSessionRequest is the request body for POST /v1/isolated/session. +type CreateIsolatedSessionRequest struct { + Profile string `json:"profile"` // "strict" | "balanced" + Workspace WorkspaceSpec `json:"workspace" validate:"required"` + ExtraWritable []string `json:"extra_writable,omitempty"` + Binds []BindMount `json:"binds,omitempty"` + ShareNet *bool `json:"share_net,omitempty"` + EnvPassthrough EnvPassthroughSpec `json:"env_passthrough,omitempty"` + Uid *uint32 `json:"uid,omitempty"` + Gid *uint32 `json:"gid,omitempty"` + UidMode string `json:"uid_mode,omitempty"` // "setpriv" (default) | "userns" + IdleTimeoutSeconds int `json:"idle_timeout_seconds,omitempty"` +} + +// WorkspaceSpec describes the workspace mount. +type WorkspaceSpec struct { + Path string `json:"path" validate:"required"` + Mode string `json:"mode,omitempty"` // "rw" | "overlay" | "ro", default per profile +} + +// EnvPassthroughSpec controls environment passthrough into the namespace. +type EnvPassthroughSpec struct { + Mode string `json:"mode,omitempty"` // "deny" | "allow" + Keys []string `json:"keys,omitempty"` +} + +// BindMount describes an explicit source→dest bind mount into the namespace. +type BindMount struct { + Source string `json:"source" validate:"required"` + Dest string `json:"dest,omitempty"` + ReadOnly bool `json:"readonly,omitempty"` +} + +// IsolatedCreateSessionResponse is the response for POST /v1/isolated/session. +type IsolatedCreateSessionResponse struct { + SessionID string `json:"session_id"` + CreatedAt time.Time `json:"created_at"` +} + +// Validate checks CreateIsolatedSessionRequest fields. +func (r *CreateIsolatedSessionRequest) Validate() error { + v := validator.New() + if err := v.Struct(r); err != nil { + return err + } + if r.Workspace.Mode != "" { + switch r.Workspace.Mode { + case WorkspaceModeRW, WorkspaceModeOverlay, WorkspaceModeRO: + default: + return fmt.Errorf("invalid workspace mode %q: must be %s, %s, or %s", + r.Workspace.Mode, WorkspaceModeRW, WorkspaceModeOverlay, WorkspaceModeRO) + } + } + if r.EnvPassthrough.Mode != "" { + switch r.EnvPassthrough.Mode { + case "deny", "allow": + default: + return fmt.Errorf("invalid env_passthrough mode %q: must be \"deny\" or \"allow\"", + r.EnvPassthrough.Mode) + } + } + if r.UidMode != "" { + switch r.UidMode { + case "setpriv", "userns": + default: + return fmt.Errorf("invalid uid_mode %q: must be \"setpriv\" or \"userns\"", + r.UidMode) + } + } + for i, b := range r.Binds { + if b.Source == "" { + return fmt.Errorf("binds[%d].source is required", i) + } + if !strings.HasPrefix(b.Source, "/") { + return fmt.Errorf("binds[%d].source %q must be an absolute path", i, b.Source) + } + if b.Dest != "" && !strings.HasPrefix(b.Dest, "/") { + return fmt.Errorf("binds[%d].dest %q must be an absolute path", i, b.Dest) + } + } + return nil +} + +// Run + +// IsolatedRunRequest is the request body for POST /v1/isolated/session//run. +type IsolatedRunRequest struct { + Code string `json:"code" validate:"required"` + Envs map[string]string `json:"envs,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty" validate:"omitempty,gte=0"` +} + +// Validate checks IsolatedRunRequest fields. +func (r *IsolatedRunRequest) Validate() error { + v := validator.New() + return v.Struct(r) +} + +// Session State + +// SessionState is returned by GET /v1/isolated/session/. +type SessionState struct { + Status string `json:"status"` // "active" | "destroyed" + CreatedAt time.Time `json:"created_at"` + LastRunAt time.Time `json:"last_run_at"` + IdleRemainingSeconds *int `json:"idle_remaining_seconds,omitempty"` +} + +// IsolatedSessionSummary describes a single session in a list response. +type IsolatedSessionSummary struct { + SessionID string `json:"session_id"` + Status string `json:"status"` // "active" | "dead" + CreatedAt time.Time `json:"created_at"` + LastRunAt time.Time `json:"last_run_at"` + IdleRemainingSeconds *int `json:"idle_remaining_seconds,omitempty"` +} + +// ListIsolatedSessionsResponse is returned by GET /v1/isolated/sessions. +type ListIsolatedSessionsResponse struct { + Sessions []IsolatedSessionSummary `json:"sessions"` +} + +// Capabilities + +// CapabilitiesResponse is returned by GET /v1/isolated/capabilities. +type CapabilitiesResponse struct { + Available bool `json:"available"` + Isolator string `json:"isolator,omitempty"` + Version string `json:"version,omitempty"` + Message string `json:"message,omitempty"` + CommitSupported bool `json:"commit_supported"` + DiffSupported bool `json:"diff_supported"` +} diff --git a/components/execd/pkg/web/model/isolated_session_test.go b/components/execd/pkg/web/model/isolated_session_test.go new file mode 100644 index 0000000..5dbd742 --- /dev/null +++ b/components/execd/pkg/web/model/isolated_session_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "strings" + "testing" +) + +func baseIsolatedReq() CreateIsolatedSessionRequest { + return CreateIsolatedSessionRequest{ + Workspace: WorkspaceSpec{Path: "/tmp", Mode: "rw"}, + } +} + +func TestCreateIsolatedSessionRequest_Validate_Binds(t *testing.T) { + tests := []struct { + name string + binds []BindMount + wantErr string // substring; "" means no error + }{ + {"valid absolute src and dest", []BindMount{{Source: "/data/in", Dest: "/mnt/in"}}, ""}, + {"valid src only (dest defaults)", []BindMount{{Source: "/data/in"}}, ""}, + {"valid readonly", []BindMount{{Source: "/data/in", Dest: "/mnt/in", ReadOnly: true}}, ""}, + {"missing source", []BindMount{{Dest: "/mnt/in"}}, "source is required"}, + {"relative source", []BindMount{{Source: "data/in"}}, "must be an absolute path"}, + {"relative dest", []BindMount{{Source: "/data/in", Dest: "mnt/in"}}, "must be an absolute path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := baseIsolatedReq() + req.Binds = tt.binds + err := req.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} diff --git a/components/execd/pkg/web/model/metric.go b/components/execd/pkg/web/model/metric.go new file mode 100644 index 0000000..5a45a7e --- /dev/null +++ b/components/execd/pkg/web/model/metric.go @@ -0,0 +1,36 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import "time" + +// Metrics represents system resource usage metrics +type Metrics struct { + CpuCount float64 `json:"cpu_count"` + CpuUsedPct float64 `json:"cpu_used_pct"` + MemTotalMiB float64 `json:"mem_total_mib"` + MemUsedMiB float64 `json:"mem_used_mib"` + Timestamp int64 `json:"timestamp"` +} + +func NewMetrics() *Metrics { + return &Metrics{ + CpuCount: 0, + CpuUsedPct: 0, + MemTotalMiB: 0, + MemUsedMiB: 0, + Timestamp: time.Now().UnixMilli(), + } +} diff --git a/components/execd/pkg/web/model/pty.go b/components/execd/pkg/web/model/pty.go new file mode 100644 index 0000000..e6d5ca0 --- /dev/null +++ b/components/execd/pkg/web/model/pty.go @@ -0,0 +1,33 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +// CreatePTYSessionRequest is the request body for POST /pty. +type CreatePTYSessionRequest struct { + Cwd string `json:"cwd,omitempty"` + Command string `json:"command,omitempty"` +} + +// CreatePTYSessionResponse is the response for POST /pty. +type CreatePTYSessionResponse struct { + SessionID string `json:"session_id"` +} + +// PTYSessionStatusResponse is the response for GET /pty/:sessionId. +type PTYSessionStatusResponse struct { + SessionID string `json:"session_id"` + Running bool `json:"running"` + OutputOffset int64 `json:"output_offset"` +} diff --git a/components/execd/pkg/web/model/pty_ws.go b/components/execd/pkg/web/model/pty_ws.go new file mode 100644 index 0000000..76b2904 --- /dev/null +++ b/components/execd/pkg/web/model/pty_ws.go @@ -0,0 +1,62 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +// ClientFrame is a JSON frame sent from the WebSocket client to the server. +type ClientFrame struct { + Type string `json:"type"` + Data string `json:"data,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + Signal string `json:"signal,omitempty"` +} + +// ServerFrame is a JSON frame sent from the server to the WebSocket client. +type ServerFrame struct { + Type string `json:"type"` + SessionID string `json:"session_id,omitempty"` + Mode string `json:"mode,omitempty"` + Data string `json:"data,omitempty"` + Offset int64 `json:"offset,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +// Binary WebSocket frame type bytes — prefix byte for all binary frames. +const ( + BinStdin byte = 0x00 // Client → Server: raw stdin bytes + BinStdout byte = 0x01 // Server → Client: raw stdout bytes + BinStderr byte = 0x02 // Server → Client: raw stderr bytes (pipe mode) + BinReplay byte = 0x03 // Server → Client: [8 bytes int64 BE offset][raw bytes] +) + +// WebSocket error codes sent in ServerFrame.Code. +const ( + WSErrCodeSessionGone = "SESSION_GONE" + WSErrCodeStartFailed = "START_FAILED" + WSErrCodeStdinWriteFailed = "STDIN_WRITE_FAILED" + WSErrCodeInvalidFrame = "INVALID_FRAME" + WSErrCodeAlreadyConnected = "ALREADY_CONNECTED" + WSErrCodeTakenOver = "TAKEN_OVER" + WSErrCodeRuntimeError = "RUNTIME_ERROR" +) + +// WSCloseTakenOver is the WebSocket close code sent to a client whose session was +// taken over by another client (via ?takeover=1). It lives in the application-private +// range (4000–4999, RFC 6455 §7.4.2) so clients can distinguish an intentional +// handoff from a network drop and avoid auto-reconnecting into the new holder. +const WSCloseTakenOver = 4001 diff --git a/components/execd/pkg/web/model/session.go b/components/execd/pkg/web/model/session.go new file mode 100644 index 0000000..13b3f6f --- /dev/null +++ b/components/execd/pkg/web/model/session.go @@ -0,0 +1,47 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "github.com/go-playground/validator/v10" + + "github.com/alibaba/opensandbox/execd/pkg/runtime" +) + +// CreateSessionRequest is the request body for creating a bash session. +type CreateSessionRequest struct { + Cwd string `json:"cwd,omitempty"` +} + +// CreateSessionResponse is the response for create_session. +type CreateSessionResponse struct { + SessionID string `json:"session_id"` +} + +// RunInSessionRequest is the request body for running a command in an existing session. +type RunInSessionRequest struct { + Command string `json:"command" validate:"required"` + Cwd string `json:"cwd,omitempty"` + Timeout int64 `json:"timeout,omitempty" validate:"omitempty,gte=0"` +} + +// Validate validates RunInSessionRequest. +func (r *RunInSessionRequest) Validate() error { + validate := validator.New() + if err := validate.Struct(r); err != nil { + return err + } + return runtime.ValidateWorkingDir(r.Cwd) +} diff --git a/components/execd/pkg/web/otel_middleware.go b/components/execd/pkg/web/otel_middleware.go new file mode 100644 index 0000000..b01fde2 --- /dev/null +++ b/components/execd/pkg/web/otel_middleware.go @@ -0,0 +1,38 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "time" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/telemetry" +) + +func otelHTTPMetricsMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + start := time.Now() + ctx.Next() + + telemetry.RecordHTTPRequest( + ctx.Request.Context(), + ctx.Request.Method, + ctx.FullPath(), + ctx.Writer.Status(), + float64(time.Since(start))/float64(time.Millisecond), + ) + } +} diff --git a/components/execd/pkg/web/proxy.go b/components/execd/pkg/web/proxy.go new file mode 100644 index 0000000..ee36671 --- /dev/null +++ b/components/execd/pkg/web/proxy.go @@ -0,0 +1,118 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/log" +) + +func ProxyMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if !strings.HasPrefix(c.Request.URL.Path, "/proxy/") { + c.Next() + return + } + + r := c.Request + w := c.Writer + + rest := strings.TrimPrefix(r.URL.Path, "/proxy/") + parts := strings.SplitN(rest, "/", 2) + if len(parts) == 0 || parts[0] == "" { + http.Error(w, "port is required", http.StatusBadRequest) + c.Abort() + return + } + + port := parts[0] + path := "/" + if len(parts) == 2 && parts[1] != "" { + path += parts[1] + } + + target := &url.URL{ + Scheme: "http", + Host: "127.0.0.1:" + port, + Path: path, + } + + isWebSocket := strings.ToLower(r.Header.Get("Upgrade")) == "websocket" + + proxy := httputil.NewSingleHostReverseProxy(target) + // Flush SSE chunks promptly; a small interval avoids buffering breaks chunked streams. + proxy.FlushInterval = 200 * time.Millisecond + + proxy.Director = func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = "127.0.0.1:" + port + req.URL.Path = path + req.URL.RawQuery = r.URL.RawQuery + req.URL.RawPath = "" + req.RequestURI = "" + + req.Header.Set("X-Forwarded-For", getClientIP(r)) + req.Header.Set("X-Forwarded-Proto", "http") + req.Header.Del("X-Forwarded-Host") + + if isWebSocket { + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Sec-WebSocket-Version", "13") + if key := r.Header.Get("Sec-WebSocket-Key"); key != "" { + req.Header.Set("Sec-WebSocket-Key", key) + } + } + } + + proxy.Transport = &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 600 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 600 * time.Second, + } + + proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) { + log.Error("Proxy error: %v, request: %s %s", err, req.Method, req.RequestURI) + http.Error(rw, "Bad Gateway", http.StatusBadGateway) + } + + log.Info("Proxy: %s %s -> %s (WebSocket: %v)", r.Method, r.RequestURI, target.Host, isWebSocket) + + proxy.ServeHTTP(w, r) + c.Abort() + } +} + +func getClientIP(r *http.Request) string { + if ip := r.Header.Get("X-Forwarded-For"); ip != "" { + return strings.Split(ip, ",")[0] + } + if ip := r.Header.Get("X-Real-IP"); ip != "" { + return ip + } + return r.RemoteAddr +} diff --git a/components/execd/pkg/web/proxy_test.go b/components/execd/pkg/web/proxy_test.go new file mode 100644 index 0000000..0960b78 --- /dev/null +++ b/components/execd/pkg/web/proxy_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestProxyMiddlewareReturnsSidecarForbiddenForActiveVault(t *testing.T) { + gin.SetMode(gin.TestMode) + receivedPath := make(chan string, 1) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath <- r.URL.Path + http.Error(w, "forbidden", http.StatusForbidden) + })) + defer backend.Close() + + backendURL, err := url.Parse(backend.URL) + require.NoError(t, err) + _, port, err := net.SplitHostPort(backendURL.Host) + require.NoError(t, err) + + router := gin.New() + router.Use(ProxyMiddleware()) + proxyServer := httptest.NewServer(router) + defer proxyServer.Close() + + req, err := http.NewRequest(http.MethodGet, proxyServer.URL+"/proxy/"+port+"/credential-vault/_active", nil) + require.NoError(t, err) + resp, err := proxyServer.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusForbidden, resp.StatusCode) + require.Equal(t, "/credential-vault/_active", <-receivedPath) +} diff --git a/components/execd/pkg/web/router.go b/components/execd/pkg/web/router.go new file mode 100644 index 0000000..5c3a008 --- /dev/null +++ b/components/execd/pkg/web/router.go @@ -0,0 +1,175 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/alibaba/opensandbox/execd/pkg/log" + "github.com/alibaba/opensandbox/execd/pkg/web/controller" + "github.com/alibaba/opensandbox/execd/pkg/web/model" +) + +// NewRouter builds a Gin engine with all execd routes. +func NewRouter(accessToken string) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + r.Use(logMiddleware(), otelHTTPMetricsMiddleware(), accessTokenMiddleware(accessToken), ProxyMiddleware()) + + r.GET("/ping", controller.PingHandler) + + files := r.Group("/files") + { + files.DELETE("", withFilesystem(func(c *controller.FilesystemController) { c.RemoveFiles() })) + files.GET("/info", withFilesystem(func(c *controller.FilesystemController) { c.GetFilesInfo() })) + files.POST("/mv", withFilesystem(func(c *controller.FilesystemController) { c.RenameFiles() })) + files.POST("/permissions", withFilesystem(func(c *controller.FilesystemController) { c.ChmodFiles() })) + files.GET("/search", withFilesystem(func(c *controller.FilesystemController) { c.SearchFiles() })) + files.POST("/replace", withFilesystem(func(c *controller.FilesystemController) { c.ReplaceContent() })) + files.POST("/upload", withFilesystem(func(c *controller.FilesystemController) { c.UploadFile() })) + files.GET("/download", withFilesystem(func(c *controller.FilesystemController) { c.DownloadFile() })) + } + + directories := r.Group("/directories") + { + directories.GET("/list", withFilesystem(func(c *controller.FilesystemController) { c.ListDirectory() })) + directories.POST("", withFilesystem(func(c *controller.FilesystemController) { c.MakeDirs() })) + directories.DELETE("", withFilesystem(func(c *controller.FilesystemController) { c.RemoveDirs() })) + } + + code := r.Group("/code") + { + code.POST("", withCode(func(c *controller.CodeInterpretingController) { c.RunCode() })) + code.DELETE("", withCode(func(c *controller.CodeInterpretingController) { c.InterruptCode() })) + code.POST("/context", withCode(func(c *controller.CodeInterpretingController) { c.CreateContext() })) + code.GET("/contexts", withCode(func(c *controller.CodeInterpretingController) { c.ListContexts() })) + code.DELETE("/contexts", withCode(func(c *controller.CodeInterpretingController) { c.DeleteContextsByLanguage() })) + code.DELETE("/contexts/:contextId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteContext() })) + code.GET("/contexts/:contextId", withCode(func(c *controller.CodeInterpretingController) { c.GetContext() })) + } + + session := r.Group("/session") + { + session.POST("", withCode(func(c *controller.CodeInterpretingController) { c.CreateSession() })) + session.POST("/:sessionId/run", withCode(func(c *controller.CodeInterpretingController) { c.RunInSession() })) + session.DELETE("/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteSession() })) + } + + command := r.Group("/command") + { + command.POST("", withCode(func(c *controller.CodeInterpretingController) { c.RunCommand() })) + command.DELETE("", withCode(func(c *controller.CodeInterpretingController) { c.InterruptCommand() })) + command.GET("/status/:id", withCode(func(c *controller.CodeInterpretingController) { c.GetCommandStatus() })) + command.GET("/:id/logs", withCode(func(c *controller.CodeInterpretingController) { c.GetBackgroundCommandOutput() })) + } + + metric := r.Group("/metrics") + { + metric.GET("", withMetric(func(c *controller.MetricController) { c.GetMetrics() })) + metric.GET("/watch", withMetric(func(c *controller.MetricController) { c.WatchMetrics() })) + } + + pty := r.Group("/pty") + { + pty.POST("", withPTY(func(c *controller.PTYController) { c.CreatePTYSession() })) + pty.GET("/:sessionId", withPTY(func(c *controller.PTYController) { c.GetPTYSessionStatus() })) + pty.DELETE("/:sessionId", withPTY(func(c *controller.PTYController) { c.DeletePTYSession() })) + pty.GET("/:sessionId/ws", controller.PTYSessionWebSocket) + } + + isolated := r.Group("/v1/isolated") + { + isolated.POST("/session", withIsolated(func(c *controller.IsolatedSessionController) { c.Create() })) + isolated.GET("/sessions", withIsolated(func(c *controller.IsolatedSessionController) { c.List() })) + isolated.GET("/session/:sessionId", withIsolated(func(c *controller.IsolatedSessionController) { c.Get() })) + isolated.POST("/session/:sessionId/run", withIsolated(func(c *controller.IsolatedSessionController) { c.Run() })) + isolated.DELETE("/session/:sessionId", withIsolated(func(c *controller.IsolatedSessionController) { c.Delete() })) + isolated.GET("/session/:sessionId/diff", withIsolated(func(c *controller.IsolatedSessionController) { c.Diff() })) + isolated.POST("/session/:sessionId/commit", withIsolated(func(c *controller.IsolatedSessionController) { c.Commit() })) + isolated.GET("/session/:sessionId/files/info", withIsolated(func(c *controller.IsolatedSessionController) { c.GetFilesInfo() })) + isolated.GET("/session/:sessionId/files/download", withIsolated(func(c *controller.IsolatedSessionController) { c.DownloadFile() })) + isolated.POST("/session/:sessionId/files/upload", withIsolated(func(c *controller.IsolatedSessionController) { c.UploadFile() })) + isolated.DELETE("/session/:sessionId/files", withIsolated(func(c *controller.IsolatedSessionController) { c.RemoveFiles() })) + isolated.POST("/session/:sessionId/files/mv", withIsolated(func(c *controller.IsolatedSessionController) { c.RenameFiles() })) + isolated.POST("/session/:sessionId/files/permissions", withIsolated(func(c *controller.IsolatedSessionController) { c.ChmodFiles() })) + isolated.POST("/session/:sessionId/files/replace", withIsolated(func(c *controller.IsolatedSessionController) { c.ReplaceContent() })) + isolated.GET("/session/:sessionId/files/search", withIsolated(func(c *controller.IsolatedSessionController) { c.SearchFiles() })) + isolated.GET("/session/:sessionId/directories/list", withIsolated(func(c *controller.IsolatedSessionController) { c.ListDirectory() })) + isolated.POST("/session/:sessionId/directories", withIsolated(func(c *controller.IsolatedSessionController) { c.MakeDirs() })) + isolated.DELETE("/session/:sessionId/directories", withIsolated(func(c *controller.IsolatedSessionController) { c.RemoveDirs() })) + isolated.GET("/capabilities", withIsolated(func(c *controller.IsolatedSessionController) { c.Capabilities() })) + } + + return r +} + +func withFilesystem(fn func(*controller.FilesystemController)) gin.HandlerFunc { + return func(ctx *gin.Context) { + fn(controller.NewFilesystemController(ctx)) + } +} + +func withCode(fn func(*controller.CodeInterpretingController)) gin.HandlerFunc { + return func(ctx *gin.Context) { + fn(controller.NewCodeInterpretingController(ctx)) + } +} + +func withMetric(fn func(*controller.MetricController)) gin.HandlerFunc { + return func(ctx *gin.Context) { + fn(controller.NewMetricController(ctx)) + } +} + +func withPTY(fn func(*controller.PTYController)) gin.HandlerFunc { + return func(ctx *gin.Context) { + fn(controller.NewPTYController(ctx)) + } +} + +func withIsolated(fn func(*controller.IsolatedSessionController)) gin.HandlerFunc { + return func(ctx *gin.Context) { + fn(controller.NewIsolatedSessionController(ctx)) + } +} + +func accessTokenMiddleware(token string) gin.HandlerFunc { + return func(ctx *gin.Context) { + if token == "" { + ctx.Next() + return + } + + requestedToken := ctx.GetHeader(model.ApiAccessTokenHeader) + if requestedToken == "" || requestedToken != token { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, map[string]any{ + "error": "Unauthorized: invalid or missing header " + model.ApiAccessTokenHeader, + }) + return + } + + ctx.Next() + } +} + +func logMiddleware() gin.HandlerFunc { + return func(ctx *gin.Context) { + log.Info("Requested: %v - %v", ctx.Request.Method, ctx.Request.URL.String()) + ctx.Next() + } +} diff --git a/components/execd/tests/jupyter.sh b/components/execd/tests/jupyter.sh new file mode 100755 index 0000000..db7aaf4 --- /dev/null +++ b/components/execd/tests/jupyter.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +export JUPYTER_PORT=54321 +export JUPYTER_TOKEN=opensandboxexecdintegrationtest + +install_jupyter() { + # install jupyter notebook for integration testing + python --version + pip install ipykernel jupyter + + echo "Starting jupyter notebook ..." + jupyter notebook --ip=0.0.0.0 --port=$JUPYTER_PORT --allow-root --no-browser --NotebookApp.token=$JUPYTER_TOKEN >/tmp/jupyter.log 2>&1 & + + sleep 3 +} diff --git a/components/execd/tests/sigterm_forward.sh b/components/execd/tests/sigterm_forward.sh new file mode 100755 index 0000000..cd17f1b --- /dev/null +++ b/components/execd/tests/sigterm_forward.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Test: bootstrap.sh forwards K8s SIGTERM to user process. +# +# Simulates K8s termination: send SIGTERM to bootstrap process, +# verify user process receives it via trap marker file. +# +# Usage: +# cd components/execd +# bash tests/test_sigterm_forward.sh + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BOOTSTRAP="$ROOT_DIR/bootstrap.sh" + +TESTDIR="$(mktemp -d)" +trap 'rm -rf "$TESTDIR"' EXIT + +MARKER_STARTED="$TESTDIR/started" +MARKER_SIGTERM="$TESTDIR/sigterm_received" + +# Write test helper: traps SIGTERM, writes marker, then exits. +# Must use shell builtin loop so bash stays alive as PID and trap can fire. +# Without this, bash -c exec's child process and trap handler is lost. +HELPER="$TESTDIR/sigterm_helper.sh" +cat > "$HELPER" << 'HELPER_SCRIPT' +#!/bin/bash +MARKER_STARTED="$1" +MARKER_SIGTERM="$2" +trap 'touch "$MARKER_SIGTERM"; exit 0' TERM +touch "$MARKER_STARTED" +while true; do + sleep 1 +done +HELPER_SCRIPT +chmod +x "$HELPER" + +echo "=== Test: SIGTERM forwarding from bootstrap to user process ===" + +# Start bootstrap with helper as user process +BOOTSTRAP_SHELL="$(command -v bash)" \ +BOOTSTRAP_CMD="$HELPER $MARKER_STARTED $MARKER_SIGTERM" \ +bash "$BOOTSTRAP" & +BOOTSTRAP_PID=$! + +# Wait for user process to signal it's alive +WAIT_OK="" +for i in $(seq 1 10); do + if [ -f "$MARKER_STARTED" ]; then + WAIT_OK="yes" + break + fi + sleep 1 +done +if [ -z "$WAIT_OK" ]; then + echo "FAIL: user process did not start within 10s" + kill "$BOOTSTRAP_PID" 2>/dev/null || true + exit 1 +fi +echo "OK: user process started (PID: $BOOTSTRAP_PID tree)" + +# Simulate K8s: send SIGTERM to bootstrap process +echo "Sending SIGTERM to bootstrap PID $BOOTSTRAP_PID ..." +kill -TERM "$BOOTSTRAP_PID" + +# Wait for signal propagation and handler execution +sleep 3 + +# Verify user process received SIGTERM +if [ -f "$MARKER_SIGTERM" ]; then + echo "PASS: user process received SIGTERM from bootstrap" +else + echo "FAIL: user process did NOT receive SIGTERM" + echo " Bootstrap PID: $BOOTSTRAP_PID (still running: $(kill -0 "$BOOTSTRAP_PID" 2>/dev/null && echo yes || echo no))" + echo " Process tree:" + pgrep -P "$BOOTSTRAP_PID" 2>/dev/null | while read -r pid; do + echo " child PID $pid: $(ps -p "$pid" -o comm= 2>/dev/null || echo dead)" + pgrep -P "$pid" 2>/dev/null | while read -r child; do + echo " grandchild PID $child: $(ps -p "$child" -o comm= 2>/dev/null || echo dead)" + done + done + exit 1 +fi diff --git a/components/execd/tests/smoke.sh b/components/execd/tests/smoke.sh new file mode 100755 index 0000000..f473c36 --- /dev/null +++ b/components/execd/tests/smoke.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +set -euxo pipefail + +source tests/jupyter.sh +install_jupyter + +export EXECD_API_GRACE_SHUTDOWN=500ms +export EXECD_LOG_FILE=execd.log +./bin/execd -jupyter-host=http://127.0.0.1:${JUPYTER_PORT} --jupyter-token=${JUPYTER_TOKEN} --log-level=7 >startup.log 2>&1 & diff --git a/components/execd/tests/smoke_api.py b/components/execd/tests/smoke_api.py new file mode 100644 index 0000000..c295dff --- /dev/null +++ b/components/execd/tests/smoke_api.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 + +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Simple smoke tests for execd APIs. + +Prerequisites: +- execd server running locally (default http://localhost:44772) +- Optional: set env BASE_URL to override +- Optional: set env API_TOKEN if server expects X-EXECD-ACCESS-TOKEN +""" + +import json +import os +import sys +import time +import uuid +import tempfile +import pathlib + +import requests + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:44772").rstrip("/") +API_TOKEN = os.environ.get("API_TOKEN") + +HEADERS = {} +if API_TOKEN: + HEADERS["X-EXECD-ACCESS-TOKEN"] = API_TOKEN + +session = requests.Session() +session.headers.update(HEADERS) + + +def expect(cond: bool, msg: str): + if not cond: + raise SystemExit(msg) + + +def sse_get_command_id() -> str: + url = f"{BASE_URL}/command" + payload = {"command": "echo smoke-command && sleep 1", "background": True} + with session.post(url, json=payload, stream=True, timeout=15) as resp: + expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}") + for line in resp.iter_lines(): + if not line or not line.startswith(b"data:"): + # controller emits raw JSON lines without SSE 'data:' prefix + try: + data = json.loads(line.decode()) + except Exception: + continue + else: + data = json.loads(line[len(b"data:") :].decode()) + if data.get("type") == "init": + cmd_id = data.get("text") + expect(cmd_id, "missing command id in init event") + return cmd_id + raise SystemExit("Failed to obtain command id from SSE") + + +def wait_status(cmd_id: str, timeout: float = 15.0) -> dict: + url = f"{BASE_URL}/command/status/{cmd_id}" + deadline = time.time() + timeout + last = None + while time.time() < deadline: + r = session.get(url, timeout=5) + expect(r.status_code == 200, f"status failed: {r.status_code} {r.text}") + last = r.json() + if not last.get("running", True): + return last + time.sleep(0.3) + return last + + +def fetch_logs(cmd_id: str, cursor: int = 0): + url = f"{BASE_URL}/command/{cmd_id}/logs" + r = session.get(url, params={"cursor": cursor}, timeout=10) + expect(r.status_code == 200, f"logs failed: {r.status_code} {r.text}") + return r.text, r.headers.get("EXECD-COMMANDS-TAIL-CURSOR") + + +def run_command_blank_lines(): + """ + Foreground command whose stdout contains consecutive newlines must surface + blank-line events instead of dropping them. Regression test for the + readFromPos fix that preserves empty lines (a\n\nb -> ["a", "\n", "b"]). + """ + url = f"{BASE_URL}/command" + # Pick a shell-native command per platform so the regression covers both + # POSIX (LF-only) and Windows cmd (CRLF) byte streams without depending on + # Git for Windows / MSYS argv mangling. The execd reader collapses CRLF to + # LF, so both produce ["a", "\n", "b", "\n", "\n", "c"]. + if os.name == "nt": + # cmd /C echo chain: each segment writes "\r\n"; "echo." writes + # a bare "\r\n". Order is deterministic because "&" is sequential. + command = "echo a&echo.&echo b&echo.&echo.&echo c" + else: + # printf emits exact bytes: a\n\nb\n\n\nc\n + command = "printf 'a\\n\\nb\\n\\n\\nc\\n'" + payload = { + "command": command, + "background": False, + } + + stdout_texts = [] + saw_complete = False + with session.post(url, json=payload, stream=True, timeout=15) as resp: + expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}") + for line in resp.iter_lines(): + if not line: + continue + try: + if line.startswith(b"data:"): + data = json.loads(line[len(b"data:") :].decode()) + else: + data = json.loads(line.decode()) + except Exception: + continue + event_type = data.get("type") + if event_type == "stdout": + stdout_texts.append(data.get("text", "")) + elif event_type == "execution_complete": + saw_complete = True + break + + expect(saw_complete, "did not observe execution_complete") + want = ["a", "\n", "b", "\n", "\n", "c"] + expect( + stdout_texts == want, + f"blank-line stdout sequence mismatch: got {stdout_texts!r}, want {want!r}", + ) + + +def sse_disconnect_should_stop_ping(): + """ + Open an SSE stream for a long-running command, receive init, then close the + client side early to ensure the server handles disconnects (ping loop should + stop). We verify the server is still responsive afterwards. + """ + url = f"{BASE_URL}/command" + payload = { + # long command so the server would keep pinging if not cancelled + "command": "sh -c 'echo long-run-start && sleep 20 && echo long-run-end'", + "background": False, + } + + with session.post(url, json=payload, stream=True, timeout=10) as resp: + expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}") + for line in resp.iter_lines(): + if not line: + continue + try: + if line.startswith(b"data:"): + data = json.loads(line[len(b"data:") :].decode()) + else: + data = json.loads(line.decode()) + except Exception: + continue + if data.get("type") == "init": + break + # explicitly close to simulate client drop + resp.close() + + # Give server a moment to observe disconnect and ensure API remains healthy + time.sleep(1) + pong = session.get(f"{BASE_URL}/ping", timeout=5) + expect(pong.status_code == 200, "ping failed after SSE disconnect") + + +def upload_and_download(): + tmp_dir = f"/tmp/execd-smoke-{uuid.uuid4().hex}" + path = f"{tmp_dir}/hello.txt" + metadata = json.dumps({"path": path}) + files = { + "metadata": ("metadata", metadata, "application/json"), + "file": ("file", b"hello execd\n", "application/octet-stream"), + } + up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10) + expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}") + + down = session.get(f"{BASE_URL}/files/download", params={"path": path}, timeout=10) + expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}") + expect(down.content == b"hello execd\n", "downloaded content mismatch") + + +def filesystem_smoke(): + base_dir = os.path.join(tempfile.gettempdir(), f"execd-smoke-{uuid.uuid4().hex}") + sub_dir = os.path.join(base_dir, "sub") + file_path = os.path.join(sub_dir, "hello.txt") + renamed_path = os.path.join(sub_dir, "hello_renamed.txt") + home_dir = os.path.expanduser("~") + home_file_name = f"execd-smoke-home-{uuid.uuid4().hex}.txt" + home_file_abs = os.path.join(home_dir, home_file_name) + # Windows uses backslash path style by default; keep smoke path style aligned + # with platform so "~" expansion is exercised in a realistic way. + home_file_tilde = f"~\\{home_file_name}" if os.name == "nt" else f"~/{home_file_name}" + + # create dirs + mk = session.post(f"{BASE_URL}/directories", json={sub_dir: {"mode": 0}}, timeout=10) + expect(mk.status_code == 200, f"mkdir failed: {mk.status_code} {mk.text}") + + # upload a file + metadata = json.dumps({"path": file_path}) + files = { + "metadata": ("metadata", metadata, "application/json"), + "file": ("file", b"hello execd\n", "application/octet-stream"), + } + up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10) + expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}") + + # get info + info = session.get(f"{BASE_URL}/files/info", params={"path": [file_path]}, timeout=10) + expect(info.status_code == 200, f"info failed: {info.status_code} {info.text}") + + # list directory contents + listed = session.get(f"{BASE_URL}/directories/list", params={"path": sub_dir}, timeout=10) + expect(listed.status_code == 200, f"list directory failed: {listed.status_code} {listed.text}") + listed_file = None + for entry in listed.json(): + p = entry.get("path") + if p and pathlib.Path(p).resolve() == pathlib.Path(file_path).resolve(): + listed_file = entry + break + expect(listed_file is not None, "directory list did not find file") + expect(listed_file.get("type") == "file", f"directory list file type mismatch: {listed_file}") + + # search + search = session.get(f"{BASE_URL}/files/search", params={"path": base_dir, "pattern": "*.txt"}, timeout=10) + expect(search.status_code == 200, f"search failed: {search.status_code} {search.text}") + found = False + for f in search.json(): + p = f.get("path") + if not p: + continue + if pathlib.Path(p).resolve() == pathlib.Path(file_path).resolve(): + found = True + break + expect(found, "search did not find file") + + # replace content + rep = session.post( + f"{BASE_URL}/files/replace", + json={file_path: {"old": "hello", "new": "hi"}}, + timeout=10, + ) + expect(rep.status_code == 200, f"replace failed: {rep.status_code} {rep.text}") + + # download to verify replace + down = session.get(f"{BASE_URL}/files/download", params={"path": file_path}, timeout=10) + expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}") + expect(down.content == b"hi execd\n", "replace content mismatch") + + # chmod (mode only) + chmod = session.post(f"{BASE_URL}/files/permissions", json={file_path: {"mode": 644}}, timeout=10) + expect(chmod.status_code == 200, f"chmod failed: {chmod.status_code} {chmod.text}") + + # rename + mv = session.post( + f"{BASE_URL}/files/mv", + json=[{"src": file_path, "dest": renamed_path}], + timeout=10, + ) + expect(mv.status_code == 200, f"rename failed: {mv.status_code} {mv.text}") + + # remove file + rm_file = session.delete(f"{BASE_URL}/files", params={"path": [renamed_path]}, timeout=10) + expect(rm_file.status_code == 200, f"remove file failed: {rm_file.status_code} {rm_file.text}") + + # read file using "~/" style path + home_metadata = json.dumps({"path": home_file_abs}) + home_files = { + "metadata": ("metadata", home_metadata, "application/json"), + "file": ("file", b"home path content\n", "application/octet-stream"), + } + home_up = session.post(f"{BASE_URL}/files/upload", files=home_files, timeout=10) + expect(home_up.status_code == 200, f"home upload failed: {home_up.status_code} {home_up.text}") + + home_down = session.get(f"{BASE_URL}/files/download", params={"path": home_file_tilde}, timeout=10) + # On Windows, also accept "~/" form as a compatibility fallback. + if home_down.status_code != 200 and os.name == "nt": + alt_tilde = f"~/{home_file_name}" + home_down = session.get(f"{BASE_URL}/files/download", params={"path": alt_tilde}, timeout=10) + expect(home_down.status_code == 200, f"home download via tilde failed: {home_down.status_code} {home_down.text}") + expect(home_down.content == b"home path content\n", "home download content mismatch") + + home_rm = session.delete(f"{BASE_URL}/files", params={"path": [home_file_tilde]}, timeout=10) + expect(home_rm.status_code == 200, f"home remove failed: {home_rm.status_code} {home_rm.text}") + + # remove dir + rm_dir = session.delete(f"{BASE_URL}/directories", params={"path": [base_dir]}, timeout=10) + expect(rm_dir.status_code == 200, f"remove dir failed: {rm_dir.status_code} {rm_dir.text}") + + +def main(): + print(f"[+] base: {BASE_URL}") + r = session.get(f"{BASE_URL}/ping", timeout=5) + expect(r.status_code == 200, "ping failed") + print("[+] ping ok") + + sse_disconnect_should_stop_ping() + print("[+] SSE disconnect handled") + + run_command_blank_lines() + print("[+] run_command preserves blank lines") + + cmd_id = sse_get_command_id() + print(f"[+] command id: {cmd_id}") + + status = wait_status(cmd_id) + print(f"[+] status: {status}") + + logs, cursor = fetch_logs(cmd_id, cursor=0) + print(f"[+] logs (cursor={cursor}):\n{logs}") + + filesystem_smoke() + print("[+] filesystem APIs ok") + + print("[+] smoke tests PASS") + + +if __name__ == "__main__": + try: + main() + except SystemExit as exc: + print(f"[!] smoke tests FAIL: {exc}", file=sys.stderr) + sys.exit(1) \ No newline at end of file diff --git a/components/execd/tests/smoke_bwrap.sh b/components/execd/tests/smoke_bwrap.sh new file mode 100755 index 0000000..c81bfae --- /dev/null +++ b/components/execd/tests/smoke_bwrap.sh @@ -0,0 +1,161 @@ +#!/bin/bash +# Smoke test: build execd image, extract execd+bwrap, verify bwrap works. +# +# Prerequisites: docker +# +# Usage: +# bash components/execd/tests/smoke_bwrap.sh +# +# Exit 0 on success, non-zero on failure. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +SMOKE_DIR="${REPO_ROOT}/_smoke_bwrap" +IMAGE="execd-bwrap-smoke:test" + +cleanup() { + echo ">> Cleaning up..." + rm -rf "${SMOKE_DIR}" + docker rmi -f "${IMAGE}" 2>/dev/null || true +} +trap cleanup EXIT + +echo "=========================================" +echo " Smoke Test: execd + bwrap Docker Image" +echo "=========================================" + +# ------------------------------------------------------------------- +# Step 1: Build Docker image +# ------------------------------------------------------------------- +echo "" +echo ">> Step 1: Building Docker image '${IMAGE}'..." +cd "${REPO_ROOT}" +docker build \ + -f components/execd/Dockerfile \ + -t "${IMAGE}" \ + --build-arg VERSION=smoke-test \ + . + +echo ">> Image built." + +# ------------------------------------------------------------------- +# Step 2: Extract binaries from image +# ------------------------------------------------------------------- +echo "" +echo ">> Step 2: Extracting execd and bwrap from image..." +mkdir -p "${SMOKE_DIR}" +docker run --rm \ + --entrypoint "" \ + -v "${SMOKE_DIR}:/out" \ + "${IMAGE}" \ + sh -c 'cp /execd /usr/local/bin/bwrap /out/ && chmod +x /out/execd /out/bwrap' + +echo ">> Extracted:" +ls -lh "${SMOKE_DIR}/execd" "${SMOKE_DIR}/bwrap" + +# ------------------------------------------------------------------- +# Step 3: Verify bwrap is static +# ------------------------------------------------------------------- +echo "" +echo ">> Step 3: Checking bwrap is statically linked..." +if command -v ldd &>/dev/null; then + if ldd "${SMOKE_DIR}/bwrap" 2>&1 | grep -q "not a dynamic executable"; then + echo ">> bwrap is statically linked. ✓" + elif ldd "${SMOKE_DIR}/bwrap" 2>&1 | grep -q "statically linked"; then + echo ">> bwrap is statically linked. ✓" + else + echo ">> WARNING: bwrap appears dynamically linked:" + ldd "${SMOKE_DIR}/bwrap" 2>&1 || true + fi +else + file "${SMOKE_DIR}/bwrap" || true +fi + +# ------------------------------------------------------------------- +# Step 4: Smoke test bwrap +# ------------------------------------------------------------------- +echo "" +echo ">> Step 4: bwrap --version..." +"${SMOKE_DIR}/bwrap" --version 2>&1 || true + +echo "" +echo ">> Step 4b: bwrap namespace smoke test (requires root)..." +if sudo -n "${SMOKE_DIR}/bwrap" \ + --ro-bind / / \ + --proc /proc \ + --dev /dev \ + --tmpfs /tmp \ + -- sh -c 'echo "PID in namespace: $$"' 2>&1; then + echo ">> bwrap namespace smoke test PASSED. ✓" +else + echo ">> bwrap namespace smoke test SKIPPED (no root or userns disabled)." + echo ">> This is OK — execd runs as root inside sandbox containers." +fi + +# ------------------------------------------------------------------- +# Step 5: Smoke test execd with isolation probe +# ------------------------------------------------------------------- +echo "" +echo ">> Step 5: execd isolation probe..." +EXECD="${SMOKE_DIR}/execd" +BWRAP_DIR="${SMOKE_DIR}" + +# Put bwrap on PATH for execd to find. +export PATH="${BWRAP_DIR}:${PATH}" + +# Start execd in background, capture output. +# Write a minimal isolation config for the smoke test. +SMOKE_ISO_CONFIG="${SMOKE_DIR}/isolation.toml" +cat > "${SMOKE_ISO_CONFIG}" <<'TOML' +upper_root = "/tmp/execd-smoke-isolation" +TOML + +"${EXECD}" \ + --port 44773 \ + --access-token "" \ + --isolation-config "${SMOKE_ISO_CONFIG}" \ + --log-level 7 \ + & +EXECD_PID=$! + +# Wait for execd to start. +for i in $(seq 1 30); do + if curl -s http://localhost:44773/ping >/dev/null 2>&1; then + echo ">> execd started (PID ${EXECD_PID})." + break + fi + if ! kill -0 "${EXECD_PID}" 2>/dev/null; then + echo ">> execd failed to start. ✗" + exit 1 + fi + sleep 0.2 +done + +# Ping. +echo "" +echo ">> Step 5b: GET /ping..." +curl -s http://localhost:44773/ping +echo "" + +# Isolation probe logged at startup (available=false without bwrap in sandbox). +# Full probe test via /v1/isolated/capabilities deferred to Phase 2. +echo "" +echo ">> Step 5c: isolation probe logged at startup. ✓" + +# Shut down execd. +kill "${EXECD_PID}" 2>/dev/null || true +wait "${EXECD_PID}" 2>/dev/null || true +echo ">> execd stopped." + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +echo "" +echo "=========================================" +echo " Smoke Test PASSED" +echo "=========================================" +echo " bwrap: static binary, namespace works" +echo " execd: starts, serves /ping" +echo " image: ${IMAGE}" +echo "" diff --git a/components/ingress/.golangci.yml b/components/ingress/.golangci.yml new file mode 100644 index 0000000..594dd91 --- /dev/null +++ b/components/ingress/.golangci.yml @@ -0,0 +1,320 @@ +run: + skip-dirs: + - vendor + - tests + - scripts + skip-files: + - .*/zz_generated.deepcopy.go + - .*/mock/*.go + tests: false + timeout: 10m +linters-settings: + funlen: + lines: 500 + statements: 200 + gocyclo: + min-complexity: 40 + gosimple: + checks: ["S1019", "S1002"] + staticcheck: + checks: ["SA4006"] + govet: + enable: + - asmdecl + - assign + - atomic + - atomicalign + - bools + - buildtag + - cgocall + - copylocks + - deepequalerrors + - errorsas + - findcall + - framepointer + - httpresponse + - ifaceassert + - lostcancel + - nilfunc + - nilness + - reflectvaluecompare + - shift + - sigchanyzer + - sortslice + - stdmethods + - stringintconv + - testinggoroutine + - tests + - unmarshal + - unreachable + - unsafeptr + - unusedresult + - printf + disable: + - composites + - loopclosure + - fieldalignment + - shadow + - structtag + - unusedwrite + errcheck: + exclude-functions: + - flag.Set + - os.Setenv + - os.Unsetenv + - logger.Sync + - fmt.Fprintf + - fmt.Fprintln + - (io.Closer).Close + - (io.ReadCloser).Close + - (k8s.io/client-go/tools/cache.SharedInformer).AddEventHandler + nestif: + # 复杂度大于32的认为阻塞 + min-complexity: 32 + goconst: + # Minimal length of string constant. + # Default: 3 + min-len: 3 + # Minimum occurrences of constant string count to trigger issue. + # Default: 3 + min-occurrences: 3 + # Ignore test files. + # Default: false + ignore-tests: true + match-constant: false + numbers: true + min: 2 + max: 10 + ignore-calls: true + gosec: + includes: + - G101 # Look for hard coded credentials + - G102 # Bind to all interfaces + - G103 # Audit the use of unsafe block + - G104 # Audit errors not checked + - G106 # Audit the use of ssh.InsecureIgnoreHostKey + - G107 # Url provided to HTTP request as taint input + - G108 # Profiling endpoint automatically exposed on /debug/pprof + - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 + - G110 # Potential DoS vulnerability via decompression bomb + - G111 # Potential directory traversal + - G112 # Potential slowloris attack + - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) + # - G114 # Use of net/http serve function that has no support for setting timeouts + - G201 # SQL query construction using format string + - G202 # SQL query construction using string concatenation + - G203 # Use of unescaped data in HTML templates + #- G204 # Audit use of command execution + - G301 # Poor file permissions used when creating a directory + - G302 # Poor file permissions used with chmod + - G303 # Creating tempfile using a predictable path + - G304 # File path provided as taint input + - G305 # File traversal when extracting zip/tar archive + - G306 # Poor file permissions used when writing to a new file + - G307 # Deferring a method which returns an error + #- G401 # Detect the usage of DES, RC4, MD5 or SHA1 + - G402 # Look for bad TLS connection settings + - G403 # Ensure minimum RSA key length of 2048 bits + - G404 # Insecure random number source (rand) + #- G501 # Import blocklist: crypto/md5 + - G502 # Import blocklist: crypto/des + - G503 # Import blocklist: crypto/rc4 + - G504 # Import blocklist: net/http/cgi + - G505 # Import blocklist: crypto/sha1 + - G601 # Implicit memory aliasing of items from a range statement + # Exclude generated files + # Default: false + exclude-generated: true + # Filter out the issues with a lower severity than the given value. + # Valid options are: low, medium, high. + # Default: low + severity: medium + # Filter out the issues with a lower confidence than the given value. + # Valid options are: low, medium, high. + # Default: low + confidence: medium + # Concurrency value. + # Default: the number of logical CPUs usable by the current process. + concurrency: 12 + # To specify the configuration of rules. + config: + # Globals are applicable to all rules. + global: + nosec: true + show-ignored: true + audit: true + G101: + # Regexp pattern for variables and constants to find. + # Default: "(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred" + pattern: "(?i)example" + # If true, complain about all cases (even with low entropy). + # Default: false + ignore_entropy: false + # Maximum allowed entropy of the string. + # Default: "80.0" + entropy_threshold: "80.0" + per_char_threshold: "3.0" + truncate: "32" + G104: + fmt: + - Fscanf + G111: + # Regexp pattern to find potential directory traversal. + # Default: "http\\.Dir\\(\"\\/\"\\)|http\\.Dir\\('\\/'\\)" + pattern: "custom\\.Dir\\(\\)" + # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll + # Default: "0750" + G301: "0750" + # Maximum allowed permissions mode for os.OpenFile and os.Chmod + # Default: "0600" + G302: "0600" + # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile + # Default: "0600" + G306: "0600" + nilnil: + checked-types: + - ptr + - map + - chan + depguard: + rules: + prevent_unmaintained_packages: + list-mode: lax # allow unless explicitely denied + files: + - $all + - "!$test" + allow: + - $gostd + - path/filepath + deny: + - pkg: io/ioutil + desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" + - pkg: path + desc: "replaced by cross-platform package path/filepath" + gci: + # Section configuration to compare against. + # Section names are case-insensitive and may contain parameters in (). + # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`, + # If `custom-order` is `true`, it follows the order of `sections` option. + # Default: ["standard", "default"] + sections: + - standard # Standard section: captures all standard packages. + - default # Default section: contains all imports that could not be matched to another section type.: + - prefix(github.com/org/project) # Custom section: groups all imports with the specified Prefix. + - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. + - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. + - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. + # Skip generated files. + # Default: true + skip-generated: true + # Enable custom order of sections. + # If `true`, make the section order the same as the order of `sections`. + # Default: false + custom-order: true + # Drops lexical ordering for custom sections. + # Default: false + no-lex-order: true + forbidigo: + forbid: + # Forbid spew Dump, whether it is called as function or method. + # Depends on analyze-types below. + - ^spew\.(ConfigState\.)?Dump$ + # The package name might be ambiguous. + # The full import path can be used as additional criteria. + # Depends on analyze-types below. + - p: ^v1.Dump$ + pkg: ^example.com/pkg/api/v1$ + +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + # - cyclop + - decorder + - depguard + - errcheck + # - errchkjson + - errorlint + - forbidigo + # - forcetypeassert + - funlen + - ineffassign + - gocognit + - gocyclo + - goheader + - gomodguard + - goprintffuncname + - gosimple + - gosec + - grouper + - importas + - maintidx + - misspell + - nakedret + - nilerr + - nilnil + # - noctx + - nosprintfhostport + - paralleltest + - predeclared + # - promlinter + - reassign + - sqlclosecheck + - staticcheck + - tenv + - testpackage + - tparallel + # del + # - typecheck + - usestdlibvars + - nestif + - unused + - makezero + - govet + - goconst + - gci + # - rowserrcheck + # 1.59 version no new lints + # 1.58 version new lints + # - fatcontext + - canonicalheader + # 1.57 version new lints + - copyloopvar + - intrange + # 1.56 version new lints + - spancheck + # 1.55 version new lints + - gochecksumtype + - perfsprint + - sloglint + - testifylint + - mirror + - zerologlint + # 1.51 version new lints + - gocheckcompilerdirectives + # 1.50 version new lints + - testableexamples + +issues: + # Note: path identifiers are regular expressions, hence the \.go suffixes. + exclude-rules: + - path: main\.go + linters: + - forbidigo + - path: _test\.go + linters: + - dogsled + - errcheck + - goconst + - gosec + - ineffassign + - maintidx + - typecheck + - path: \.go$ + text: "should have a package comment" + - path: \.go$ + text: 'exported (.+) should have comment( \(or a comment on this block\))? or be unexported' + - path: \.go$ + text: "fmt.Sprintf can be replaced with string concatenation" diff --git a/components/ingress/DEVELOPMENT.md b/components/ingress/DEVELOPMENT.md new file mode 100644 index 0000000..279cec7 --- /dev/null +++ b/components/ingress/DEVELOPMENT.md @@ -0,0 +1,49 @@ +# Development Guide (Quick) + +## Prerequisites +- Go 1.24+ +- Docker (optional, for image build) +- Access to a Kubernetes cluster with BatchSandbox CRD installed. + +## Install deps +```bash +cd components/ingress +go mod tidy && go mod vendor +``` + +## Build & Run +```bash +make build # binary at bin/ingress with ldflags version info +./bin/ingress \ + --namespace \ + --port 28888 \ + --log-level info +``` + +## Tests & Lint +```bash +make test # go test ./... +go vet ./... # included in make build +``` + +## Docker (with build args) +```bash +docker build \ + --build-arg VERSION=$(git describe --tags --always --dirty) \ + --build-arg GIT_COMMIT=$(git rev-parse HEAD) \ + --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -t opensandbox/ingress:dev . +``` + +## Key Paths +- `main.go` — entrypoint, HTTP routes, provider initialization. +- `pkg/proxy/` — HTTP/WebSocket reverse proxy logic. +- `pkg/sandbox/` — Sandbox provider abstraction and BatchSandbox implementation. +- `version/` — build metadata (ldflags). + +## Tips +- Health check: `/status.ok` +- Proxy endpoint: `/` (routes based on `OpenSandbox-Ingress-To` header or Host) +- Env overrides: `VERSION/GIT_COMMIT/BUILD_TIME` usable via Makefile and build.sh. +- BatchSandbox must have `sandbox.opensandbox.io/endpoints` annotation with JSON array of IPs. + diff --git a/components/ingress/Dockerfile b/components/ingress/Dockerfile new file mode 100644 index 0000000..265b50b --- /dev/null +++ b/components/ingress/Dockerfile @@ -0,0 +1,66 @@ +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM golang:1.25.9 AS builder + +WORKDIR /build + +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown +ARG GOFLAGS= +ARG LDFLAGS= +ARG CGO_ENABLED=0 +ARG CC= +ARG CXX= +ARG CFLAGS= +ARG CXXFLAGS= +ARG CGO_CFLAGS= +ARG CGO_CXXFLAGS= +ARG CGO_LDFLAGS= + +COPY kubernetes ./kubernetes +# Prepare local modules to satisfy replace directives. +COPY components/internal/go.mod components/internal/go.sum ./components/internal/ +COPY components/ingress/go.mod components/ingress/go.sum ./components/ingress/ + +WORKDIR /build + +RUN cd components/internal && go mod download +RUN cd components/ingress && go mod download + +# Copy sources. +COPY components/internal ./components/internal +COPY components/ingress/. ./components/ingress + +WORKDIR /build/components/ingress + +RUN if [ -n "${CC}" ]; then export CC; fi; \ + if [ -n "${CXX}" ]; then export CXX; fi; \ + export CGO_ENABLED="${CGO_ENABLED}" \ + CGO_CFLAGS="${CGO_CFLAGS:-${CFLAGS}}" \ + CGO_CXXFLAGS="${CGO_CXXFLAGS:-${CXXFLAGS}}" \ + CGO_LDFLAGS="${CGO_LDFLAGS}"; \ + go build ${GOFLAGS} -trimpath -buildvcs=false \ + -ldflags "${LDFLAGS} -buildid= -B none \ + -X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ + -o /build/ingress ./main.go + +FROM alpine:latest + +COPY --from=builder /build/ingress . + +ENTRYPOINT ["./ingress"] diff --git a/components/ingress/Dockerfile.dockerignore b/components/ingress/Dockerfile.dockerignore new file mode 100644 index 0000000..a953a32 --- /dev/null +++ b/components/ingress/Dockerfile.dockerignore @@ -0,0 +1,6 @@ +** +!components/ingress/** +!components/internal/** +!kubernetes/** +!ingress/** +!internal/** diff --git a/components/ingress/Makefile b/components/ingress/Makefile new file mode 100644 index 0000000..ae1d08c --- /dev/null +++ b/components/ingress/Makefile @@ -0,0 +1,46 @@ +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go mod tidy && go mod vendor + go vet ./... + +.PHONY: test +test: vet ## Run tests + go test -v -coverpkg=./... ./pkg/... + +##@ Linter + +.PHONY: install-golint +install-golint: + @if ! command -v golangci-lint &> /dev/null; then \ + echo "installing golangci-lint..."; \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ + else \ + echo "golangci-lint already installed"; \ + fi + +.PHONY: golint +golint: fmt install-golint + golangci-lint run -v --fix ./... + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") +BUILD_TIME ?= $(shell if [ -n "$$SOURCE_DATE_EPOCH" ]; then date -u -d "@$$SOURCE_DATE_EPOCH" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -r "$$SOURCE_DATE_EPOCH" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null; else date -u +"%Y-%m-%dT%H:%M:%SZ"; fi) +PROJECT_GOFLAGS := -trimpath -buildvcs=false +PROJECT_LDFLAGS := -buildid= -B none -X 'github.com/alibaba/opensandbox/internal/version.Version=$(VERSION)' \ + -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=$(BUILD_TIME)' \ + -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=$(GIT_COMMIT)' +GO_BUILD_FLAGS := $(strip $(GOFLAGS) $(PROJECT_GOFLAGS)) +GO_LDFLAGS := $(strip $(LDFLAGS) $(PROJECT_LDFLAGS)) + +.PHONY: build +build: vet ## Build the binary. + @mkdir -p bin + go build $(GO_BUILD_FLAGS) -ldflags "$(GO_LDFLAGS)" -o bin/router main.go + +.PHONY: clean +clean: ## Clean build artifacts. + rm -rf bin/ vendor/ diff --git a/components/ingress/README.md b/components/ingress/README.md new file mode 100644 index 0000000..bf19ee0 --- /dev/null +++ b/components/ingress/README.md @@ -0,0 +1,4 @@ +# OpenSandbox Ingress + +Documentation: [docs/components/ingress.md](../../docs/components/ingress.md) + diff --git a/components/ingress/RELEASE_NOTES.md b/components/ingress/RELEASE_NOTES.md new file mode 100644 index 0000000..08830ca --- /dev/null +++ b/components/ingress/RELEASE_NOTES.md @@ -0,0 +1,124 @@ +# components/ingress 1.0.5 + +## What's New + +### ✨ Features +- **[EXPERIMENTAL]** publishing renew-intent to Redis for [OSEP-0009](https://github.com/alibaba/OpenSandbox/blob/main/oseps/0009-auto-renew-sandbox-on-ingress-access.md) (#480) + +### 🐛 Bug Fixes +- use LoadOrStore for renew-intent MinInterval throttle (#529) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/ingress:v1.0.5 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:v1.0.5 + +# components/ingress 1.0.4 + +## What's New + +### 🐛 Bug Fixes +- set `CGO_ENABLED=0` resolve ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2 error (#436) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/ingress:v1.0.4 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:v1.0.4 + +# components/ingress 1.0.3 + +## What's New + +### ✨ Features +- build linux/arm64 image (#330) + +### 🐛 Bug Fixes +- Fixes inconsistent sandbox resource naming between creation and lookup paths when sandbox IDs begin with digits (e.g. UUID-like IDs), which can violate Kubernetes DNS-1035 naming rules. (#318) + +### 📦 Misc +- sync latest image for v-prefixed TAG (#331) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @stablegenius49 +- @Pangjiping + +--- +- Docker Hub: opensandbox/ingress:v1.0.3 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:v1.0.3 + +# components/ingress 1.0.2 + +## What's New + +### ✨ Features +- chore: unified internal logger for components (#230) +- chore(ingress): rename ingress header to `OpenSandbox-Ingress-To` (#246) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Pangjiping + +--- +- Docker Hub: opensandbox/ingress:v1.0.2 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:v1.0.2 + +# components/ingress 1.0.1 + +## What's New + +### ✨ Features +- replace pod with batch sandbox resource (#147) +- watch agent-sandbox resource by ingress (#164) +- add `proxy mode` for ingress, support uri/header mode (#191) + +### 🐛 Bug Fixes +- do not print request header to log (#198) +- fix Dockerfile and build process (#215) +- drop linux/arm64 target (#216) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @Generalwin +- @Pangjiping + +--- +- Docker Hub: opensandbox/ingress:v1.0.1 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:v1.0.1 + + + +# components/ingress 1.0.0 + +The OpenSandbox ingress component is a Kubernetes-native traffic management component implementing transparent Layer 7 proxy routing rules based on HTTP Headers or Host, eliminating the need for Service creation on target sandbox pods. + +### ✨ Features +- add kubernetes native common ingress component (#52) + +## 👥 Contributors + +Thanks to these contributors ❤️ + +- @hittyt +- @Pangjiping + +--- + +- Docker Hub: opensandbox/ingress:v1.0.0 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:v1.0.0 diff --git a/components/ingress/build.sh b/components/ingress/build.sh new file mode 100755 index 0000000..1e19ced --- /dev/null +++ b/components/ingress/build.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Copyright 2025 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -ex + +default_build_time() { + if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then + date -u -d "@${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || + date -u -r "${SOURCE_DATE_EPOCH}" +"%Y-%m-%dT%H:%M:%SZ" + else + date -u +"%Y-%m-%dT%H:%M:%SZ" + fi +} + +build_arg_if_set() { + local name="$1" + if [[ -n "${!name+x}" ]]; then + BUILD_ARGS+=(--build-arg "${name}=${!name}") + fi +} + +TAG=${TAG:-latest} +GHCR_REPO=${GHCR_REPO:-} +VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")} +GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")} +BUILD_TIME=${BUILD_TIME:-$(default_build_time)} +BUILD_METADATA_FILE=${BUILD_METADATA_FILE:-build/ingress-image-metadata.json} +BUILD_ARGS=() +for name in GOFLAGS LDFLAGS CGO_ENABLED CC CXX CFLAGS CXXFLAGS CGO_CFLAGS CGO_CXXFLAGS CGO_LDFLAGS; do + build_arg_if_set "${name}" +done + +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..") +cd "${REPO_ROOT}" +mkdir -p "$(dirname "${BUILD_METADATA_FILE}")" + +docker buildx rm ingress-builder || true + +docker buildx create --use --name ingress-builder + +docker buildx inspect --bootstrap + +docker buildx ls + +IMAGE_TAGS=(-t opensandbox/ingress:${TAG} -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:${TAG}) +LATEST_TAGS=() +if [[ -n "${GHCR_REPO}" ]]; then + IMAGE_TAGS+=(-t "${GHCR_REPO}/ingress:${TAG}") +fi +if [[ "${TAG}" == v* ]]; then + LATEST_TAGS+=(-t opensandbox/ingress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:latest) + if [[ -n "${GHCR_REPO}" ]]; then + LATEST_TAGS+=(-t "${GHCR_REPO}/ingress:latest") + fi +fi + +docker buildx build \ + "${IMAGE_TAGS[@]}" \ + "${LATEST_TAGS[@]}" \ + -f components/ingress/Dockerfile \ + "${BUILD_ARGS[@]}" \ + --build-arg VERSION="${VERSION}" \ + --build-arg GIT_COMMIT="${GIT_COMMIT}" \ + --build-arg BUILD_TIME="${BUILD_TIME}" \ + --platform linux/amd64,linux/arm64 \ + --metadata-file "${BUILD_METADATA_FILE}" \ + --push \ + . diff --git a/components/ingress/docs/opentelemetry.md b/components/ingress/docs/opentelemetry.md new file mode 100644 index 0000000..3e8fe63 --- /dev/null +++ b/components/ingress/docs/opentelemetry.md @@ -0,0 +1,27 @@ +# OpenTelemetry Metrics — Ingress + +Meter: `opensandbox/ingress` · Service name: `opensandbox-ingress-` + +## Metrics + +| Metric | Type | Unit | Attributes | Description | +|---|---|---|---|---| +| `ingress.http.request.count` | Counter | — | `http_method`, `http_status_code`, `proxy_type` | Request count (QPS derivable) | +| `ingress.http.request.duration` | Histogram | `ms` | `http_method`, `http_status_code`, `proxy_type` | Full request duration | +| `ingress.routing.resolutions.count` | Counter | — | `routing_result` | Routing resolution count | +| `ingress.routing.resolution.duration` | Histogram | `ms` | `routing_result` | Routing resolution latency | +| `ingress.connections.active` | Observable Gauge | — | — | TCP ESTABLISHED connections (from `/proc/net/tcp`) | +| `ingress.system.cpu.usage` | Observable Gauge | `1` | — | CPU busy ratio `[0,1]` (Linux only) | +| `ingress.system.memory.usage_bytes` | Observable Gauge | `By` | — | Memory used bytes (Linux only) | + +**Attribute values:** `proxy_type`: `http` | `websocket`. `routing_result`: `success` | `not_found` | `not_ready` | `error`. + +**Temporality:** Delta. + +## Configuration + +```bash +export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://otel-collector:4318" +``` + +Fallback: `OTEL_EXPORTER_OTLP_ENDPOINT`. Both unset = no export. diff --git a/components/ingress/go.mod b/components/ingress/go.mod new file mode 100644 index 0000000..839d64f --- /dev/null +++ b/components/ingress/go.mod @@ -0,0 +1,96 @@ +module github.com/alibaba/opensandbox/ingress + +go 1.25.0 + +require ( + github.com/alibaba/OpenSandbox/sandbox-k8s v0.0.0 + github.com/alibaba/opensandbox/internal v0.0.0 + github.com/alicebob/miniredis/v2 v2.37.0 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/redis/go-redis/v9 v9.18.0 + github.com/shirou/gopsutil v3.21.11+incompatible + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 + k8s.io/apimachinery v0.34.3 + k8s.io/client-go v0.34.3 + knative.dev/pkg v0.0.0-20260120122510-4a022ed9999a +) + +require ( + github.com/blendle/zapdriver v1.3.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kelseyhightower/envconfig v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.10.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/controller-runtime v0.21.0 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace github.com/alibaba/OpenSandbox/sandbox-k8s => ../../kubernetes + +replace github.com/alibaba/opensandbox/internal => ../internal diff --git a/components/ingress/go.sum b/components/ingress/go.sum new file mode 100644 index 0000000..ae2e09e --- /dev/null +++ b/components/ingress/go.sum @@ -0,0 +1,243 @@ +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= +github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= +github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= +github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +knative.dev/pkg v0.0.0-20260120122510-4a022ed9999a h1:9f29OTA7w/iVIX6PS6yveVVzNbcUS74eQfchVe8o2/4= +knative.dev/pkg v0.0.0-20260120122510-4a022ed9999a/go.mod h1:Tz3GoxcNC5vH3Zo//cW3mnHL474u+Y1wbsUIZ11p8No= +sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= +sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/components/ingress/main.go b/components/ingress/main.go new file mode 100644 index 0000000..5331b8d --- /dev/null +++ b/components/ingress/main.go @@ -0,0 +1,120 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "knative.dev/pkg/injection" + "knative.dev/pkg/signals" + + "github.com/alibaba/opensandbox/ingress/pkg/flag" + "github.com/alibaba/opensandbox/ingress/pkg/proxy" + "github.com/alibaba/opensandbox/ingress/pkg/renewintent" + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/alibaba/opensandbox/ingress/pkg/signature" + "github.com/alibaba/opensandbox/ingress/pkg/telemetry" + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/alibaba/opensandbox/internal/version" +) + +func main() { + version.EchoVersion("OpenSandbox Ingress") + + flag.InitFlags() + + cfg := injection.ParseAndGetRESTConfigOrDie() + cfg.ContentType = runtime.ContentTypeProtobuf + cfg.UserAgent = "opensandbox-ingress/" + version.GitCommit + + ctx := signals.NewContext() + ctx = withLogger(ctx, flag.LogLevel) + + otelShutdown, err := telemetry.Init(ctx) + if err != nil { + log.Printf("OpenTelemetry metrics disabled (continuing without OTLP): %v", err) + otelShutdown = nil + } + if otelShutdown != nil { + defer func() { + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + _ = otelShutdown(shutdownCtx) + }() + } + + // Create sandbox provider factory + providerFactory := sandbox.NewProviderFactory( + cfg, + time.Second*30, // resync period + ) + + // Create sandbox provider based on provider type + sandboxProvider, err := providerFactory.CreateProvider(sandbox.ProviderType(flag.ProviderType)) + if err != nil { + log.Panicf("Failed to create sandbox provider: %v", err) + } + + // Start provider (includes cache sync) + if err := sandboxProvider.Start(ctx); err != nil { + log.Panicf("Failed to start sandbox provider: %v", err) + } + + var renewPublisher renewintent.Publisher + if flag.RenewIntentEnabled { + redisClient, err := renewintent.RedisClientFromDSN(flag.RenewIntentRedisDSN) + if err != nil { + log.Panicf("Failed to create Redis client for renew-intent: %v", err) + } + renewPublisher = renewintent.NewRedisPublisher(ctx, redisClient, renewintent.RedisPublisherConfig{ + QueueKey: flag.RenewIntentQueueKey, + QueueMaxLen: flag.RenewIntentQueueMaxLen, + MinInterval: time.Duration(flag.RenewIntentMinIntervalSec) * time.Second, + Logger: proxy.Logger, + }) + } + + var secure *signature.Verifier + if keyStr := strings.TrimSpace(flag.SecureAccessKeys); keyStr != "" { + keys, err := signature.ParseKeys(flag.SecureAccessKeys) + if err != nil { + log.Panicf("parse secure-access-keys: %v", err) + } + secure = &signature.Verifier{Keys: keys} + } + + // Create reverse proxy with sandbox provider + reverseProxy := proxy.NewProxy(ctx, sandboxProvider, proxy.Mode(flag.Mode), renewPublisher, secure) + mux := http.NewServeMux() + mux.Handle("/", reverseProxy) + mux.HandleFunc("/status.ok", proxy.Healthz) + + if err := http.ListenAndServe(fmt.Sprintf(":%v", flag.Port), mux); err != nil { + log.Panicf("Error starting http server: %v", err) + } + + panic("unreachable") +} + +func withLogger(ctx context.Context, logLevel string) context.Context { + logger := slogger.MustNew(slogger.Config{Level: logLevel}).Named("opensandbox.ingress") + return proxy.WithLogger(ctx, logger) +} diff --git a/components/ingress/pkg/flag/flags.go b/components/ingress/pkg/flag/flags.go new file mode 100644 index 0000000..fc8c23f --- /dev/null +++ b/components/ingress/pkg/flag/flags.go @@ -0,0 +1,37 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flag + +var ( + // LogLevel controls the router log verbosity. + LogLevel string + + // Port controls the HTTP listener port. + Port int + + // ProviderType specifies the sandbox provider type (e.g., batchsandbox). + ProviderType string + + // Mode specifies the sandbox service discovery mode (e.g., header, uri). + Mode string + + RenewIntentEnabled bool + RenewIntentRedisDSN string + RenewIntentQueueKey string + RenewIntentQueueMaxLen int + RenewIntentMinIntervalSec int + + SecureAccessKeys string +) diff --git a/components/ingress/pkg/flag/parser.go b/components/ingress/pkg/flag/parser.go new file mode 100644 index 0000000..161a843 --- /dev/null +++ b/components/ingress/pkg/flag/parser.go @@ -0,0 +1,41 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package flag + +import ( + "flag" +) + +var ( + deprecatedNamespace string +) + +func InitFlags() { + flag.StringVar(&LogLevel, "log-level", "info", "Server log level") + flag.IntVar(&Port, "port", 28888, "Server listening port (default: 28888)") + flag.StringVar(&deprecatedNamespace, "namespace", "opensandbox", "Deprecated compatibility flag (ingress now watches sandbox resources across all namespaces)") + flag.StringVar(&ProviderType, "provider-type", "batchsandbox", "The sandbox provider type (default: batchsandbox)") + flag.StringVar(&Mode, "mode", "header", "The sandbox service discovery mode (default: header)") + + flag.BoolVar(&RenewIntentEnabled, "renew-intent-enabled", false, "Enable publishing renew-intent events to Redis (OSEP-0009)") + flag.StringVar(&RenewIntentRedisDSN, "renew-intent-redis-dsn", "redis://127.0.0.1:6379/0", "Redis DSN for renew-intent queue") + flag.StringVar(&RenewIntentQueueKey, "renew-intent-queue-key", "opensandbox:renew:intent", "Redis List key for renew-intent payloads") + flag.IntVar(&RenewIntentQueueMaxLen, "renew-intent-queue-max-len", 0, "Max renew-intent queue length (0 = no cap)") + flag.IntVar(&RenewIntentMinIntervalSec, "renew-intent-min-interval", 60, "Min seconds between publishing intents for the same sandbox (client-side throttle)") + + flag.StringVar(&SecureAccessKeys, "secure-access-keys", "", "OSEP-0011 verification keys: a=base64,b=base64 (comma-separated; key_id is 1 char [0-9a-z])") + + flag.Parse() +} diff --git a/components/ingress/pkg/proxy/errors.go b/components/ingress/pkg/proxy/errors.go new file mode 100644 index 0000000..331168c --- /dev/null +++ b/components/ingress/pkg/proxy/errors.go @@ -0,0 +1,34 @@ +package proxy + +import ( + "errors" + "net/http" + + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/alibaba/opensandbox/ingress/pkg/signature" +) + +func ingressRouteErrHTTPStatus(err error) int { + if err == nil { + return 0 + } + if errors.Is(err, sandbox.ErrSandboxNotFound) { + return http.StatusNotFound + } + return signature.HTTPStatusForIngressErr(err) +} + +func providerErrHTTPStatus(err error) int { + if err == nil { + return 0 + } + if errors.Is(err, sandbox.ErrSandboxNotFound) { + return http.StatusNotFound + } + + if errors.Is(err, sandbox.ErrSandboxNotReady) { + return http.StatusServiceUnavailable + } + + return http.StatusBadGateway +} diff --git a/components/ingress/pkg/proxy/header.go b/components/ingress/pkg/proxy/header.go new file mode 100644 index 0000000..a44dd74 --- /dev/null +++ b/components/ingress/pkg/proxy/header.go @@ -0,0 +1,51 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import "net/http" + +var ( + XRealIP = http.CanonicalHeaderKey("X-Real-IP") + XForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For") + XForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto") + + SandboxIngress = http.CanonicalHeaderKey("OpenSandbox-Ingress-To") + // DeprecatedSandboxIngress is the deprecated header name + // Deprecated + DeprecatedSandboxIngress = http.CanonicalHeaderKey("OPEN-SANDBOX-INGRESS") + + AccessControlAllowOrigin = http.CanonicalHeaderKey("Access-Control-Allow-Origin") + ReverseProxyServerPowerBy = http.CanonicalHeaderKey("Reverse-Proxy-Server-PowerBy") + + SecWebSocketProtocol = http.CanonicalHeaderKey("Sec-WebSocket-Protocol") + SecWebSocketKey = http.CanonicalHeaderKey("Sec-WebSocket-Key") + SecWebSocketVersion = http.CanonicalHeaderKey("Sec-WebSocket-Version") + SecWebSocketExtensions = http.CanonicalHeaderKey("Sec-WebSocket-Extensions") + Cookie = http.CanonicalHeaderKey("Cookie") + SetCookie = http.CanonicalHeaderKey("Set-Cookie") + Host = http.CanonicalHeaderKey("Host") + Origin = http.CanonicalHeaderKey("Origin") + + // Hop-by-hop headers per RFC 7230 §6.1 — must not be forwarded by proxies. + HopByHopConnection = http.CanonicalHeaderKey("Connection") + HopByHopKeepAlive = http.CanonicalHeaderKey("Keep-Alive") + HopByHopProxyAuth = http.CanonicalHeaderKey("Proxy-Authenticate") + HopByHopProxyAuthz = http.CanonicalHeaderKey("Proxy-Authorization") + HopByHopTE = http.CanonicalHeaderKey("TE") + HopByHopTrailer = http.CanonicalHeaderKey("Trailer") + HopByHopTransferEncoding = http.CanonicalHeaderKey("Transfer-Encoding") + HopByHopUpgrade = http.CanonicalHeaderKey("Upgrade") + HopByHopProxyConnection = http.CanonicalHeaderKey("Proxy-Connection") +) diff --git a/components/ingress/pkg/proxy/healthz.go b/components/ingress/pkg/proxy/healthz.go new file mode 100644 index 0000000..0ebef27 --- /dev/null +++ b/components/ingress/pkg/proxy/healthz.go @@ -0,0 +1,22 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import "net/http" + +func Healthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} diff --git a/components/ingress/pkg/proxy/healthz_test.go b/components/ingress/pkg/proxy/healthz_test.go new file mode 100644 index 0000000..d1ee5a0 --- /dev/null +++ b/components/ingress/pkg/proxy/healthz_test.go @@ -0,0 +1,33 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHealthz(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rr := httptest.NewRecorder() + + Healthz(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "OK", rr.Body.String()) +} diff --git a/components/ingress/pkg/proxy/host.go b/components/ingress/pkg/proxy/host.go new file mode 100644 index 0000000..48cb8b4 --- /dev/null +++ b/components/ingress/pkg/proxy/host.go @@ -0,0 +1,137 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/alibaba/opensandbox/ingress/pkg/signature" + "github.com/alibaba/opensandbox/ingress/pkg/telemetry" +) + +type Mode string + +const ( + ModeHeader Mode = "header" + ModeURI Mode = "uri" +) + +func (p *Proxy) getSandboxHostDefinition(r *http.Request) (*sandboxHost, int, error) { + start := time.Now() + host, status, err := p.doGetSandboxHostDefinition(r) + durationMs := float64(time.Since(start)) / float64(time.Millisecond) + + result := "success" + if err != nil { + result = routingResultFromErr(err) + } + telemetry.RecordRouting(result, durationMs) + return host, status, err +} + +func routingResultFromErr(err error) string { + if errors.Is(err, sandbox.ErrSandboxNotFound) { + return "not_found" + } + if errors.Is(err, sandbox.ErrSandboxNotReady) { + return "not_ready" + } + return "error" +} + +func (p *Proxy) doGetSandboxHostDefinition(r *http.Request) (*sandboxHost, int, error) { + var pr parsedRoute + var err error + + switch p.mode { + case ModeHeader: + targetHost := p.parseTargetHostByHeader(r) + if targetHost == "" { + return nil, http.StatusBadRequest, fmt.Errorf("missing header '%s' or 'Host'", SandboxIngress) + } + pr, err = parseHostRoute(targetHost) + case ModeURI: + if r.URL == nil || r.URL.Path == "" { + return nil, http.StatusBadRequest, errors.New("missing URI path") + } + pr, err = parseURIRoute(r.URL.Path) + default: + return nil, http.StatusBadRequest, fmt.Errorf("unknown ingress mode: %s", p.mode) + } + + if err != nil || pr.sandboxID == "" || pr.port == 0 { + return nil, http.StatusBadRequest, fmt.Errorf("invalid ingress route: %w", err) + } + + endpoint, err := p.sandboxProvider.GetEndpoint(pr.sandboxID) + if err != nil { + return nil, providerErrHTTPStatus(err), err + } + + need := endpoint.AccessVerificationRequired() + + if p.mode == ModeURI && !need && pr.uriParsedAsOSEP { + pr, err = parseURILegacy(r.URL.Path) + if err != nil { + return nil, ingressRouteErrHTTPStatus(err), err + } + } + + present, accessTok := signature.SecureAccessHeaderInfo(r) + if err := signature.CheckIngressSecureAccess(signature.IngressAccessInput{ + Secure: need, + ExpectedAccessToken: endpoint.SecureAccessToken, + SecureAccessHeaderPresent: present, + RequestedAccessToken: accessTok, + ExpiresB36: pr.expiresB36, + Signature: pr.signature, + SandboxID: pr.sandboxID, + Port: pr.port, + Verifier: p.secure, + }); err != nil { + return nil, ingressRouteErrHTTPStatus(err), err + } + + return &sandboxHost{ + ingressKey: pr.sandboxID, + port: pr.port, + endpoint: endpoint.Endpoint, + requestURI: pr.requestURI, + }, 0, nil +} + +func (p *Proxy) parseTargetHostByHeader(r *http.Request) string { + targetHost := r.Header.Get(SandboxIngress) + if targetHost != "" { + return targetHost + } + deprecatedTargetHost := r.Header.Get(DeprecatedSandboxIngress) + if deprecatedTargetHost != "" { + return deprecatedTargetHost + } + + return r.Host +} + +type sandboxHost struct { + ingressKey string + port int + endpoint string + requestURI string +} diff --git a/components/ingress/pkg/proxy/host_access_matrix_test.go b/components/ingress/pkg/proxy/host_access_matrix_test.go new file mode 100644 index 0000000..b4f628d --- /dev/null +++ b/components/ingress/pkg/proxy/host_access_matrix_test.go @@ -0,0 +1,349 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/alibaba/opensandbox/ingress/pkg/signature" + "github.com/stretchr/testify/assert" +) + +// Test sandboxes: distinct ids so GetEndpoint can return with/without secure access. +const ( + testIDNoAccess = "nosec" + testIDSecure = "sec" + testToken = "annot-token" +) + +type staticEndpointProvider struct { + byID map[string]sandbox.EndpointInfo +} + +func (p staticEndpointProvider) GetEndpoint(id string) (*sandbox.EndpointInfo, error) { + if e, ok := p.byID[id]; ok { + cp := e + return &cp, nil + } + return &sandbox.EndpointInfo{Endpoint: "127.0.0.1"}, nil +} + +func (staticEndpointProvider) Start(context.Context) error { return nil } + +func newTestProvider() staticEndpointProvider { + return staticEndpointProvider{byID: map[string]sandbox.EndpointInfo{ + testIDNoAccess: {Endpoint: "10.0.0.1", SecureAccessToken: ""}, + testIDSecure: {Endpoint: "10.0.0.2", SecureAccessToken: testToken}, + }} +} + +func makeRouteSig(t *testing.T, secret []byte, key, sandboxID string, port int, expiresB36 string) string { + t.Helper() + h8 := signature.ExpectedHex8(signature.Inner(secret, signature.CanonicalBytes(sandboxID, port, expiresB36))) + return h8 + key +} + +// --- No access verification required (GetEndpoint: empty access token) --- + +func TestMatrix_NoAccessVerification_Header(t *testing.T) { + prov := newTestProvider() + secret := []byte{0x5e, 0x5e, 0x5e, 0x5e} + goodSig := makeRouteSig(t, secret, "a", testIDNoAccess, 8080, "0") + + p := &Proxy{mode: ModeHeader, sandboxProvider: prov, secure: &signature.Verifier{Keys: map[string][]byte{"a": secret}}} + + t.Run("legacy two segment no header", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDNoAccess + "-8080.sandbox.gw" + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDNoAccess, h.ingressKey) + assert.Equal(t, 8080, h.port) + }) + + t.Run("legacy two segment with OpenSandbox-Secure-Access header is ignored for routing decision", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDNoAccess + "-8080.sandbox.gw" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "irrelevant-value") + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDNoAccess, h.ingressKey) + assert.Equal(t, 8080, h.port) + }) + + t.Run("legacy with header field present but empty value (no access required still ok)", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDNoAccess + "-8080.sandbox.gw" + r.Header[signature.OpenSandboxSecureAccessCanonical] = []string{""} + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDNoAccess, h.ingressKey) + }) + + t.Run("signed shape four segment without header is accepted and signature not verified", func(t *testing.T) { + // 4+ logical segments: id-port-exp-sig; signature material is not checked when !need. + label := fmt.Sprintf("%s-8080-0-%s", testIDNoAccess, goodSig) + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = label + ".gw" + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDNoAccess, h.ingressKey) + assert.Equal(t, 8080, h.port) + }) +} + +func TestMatrix_NoAccessVerification_URI(t *testing.T) { + prov := newTestProvider() + secret := []byte{0x1a, 0x1a, 0x1a, 0x1a} + exp := strconv.FormatUint(uint64(time.Now().Add(2*time.Hour).Unix()), 36) + goodSig := makeRouteSig(t, secret, "a", testIDNoAccess, 3000, exp) + p := &Proxy{mode: ModeURI, sandboxProvider: prov, secure: &signature.Verifier{Keys: map[string][]byte{"a": secret}}} + + t.Run("legacy path no extra header", func(t *testing.T) { + path := "/" + testIDNoAccess + "/3000/v1/status" + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDNoAccess, h.ingressKey) + assert.Equal(t, 3000, h.port) + assert.Equal(t, "/v1/status", h.requestURI) + }) + + t.Run("OSEP-shaped path re-parsed with legacy (strip must not apply)", func(t *testing.T) { + // Syntactically valid signed prefix; for unsecured sandbox full path must be legacy-interpreted. + path := fmt.Sprintf("/%s/3000/%s/%s/extra/segment", testIDNoAccess, exp, goodSig) + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDNoAccess, h.ingressKey) + assert.Equal(t, 3000, h.port) + // second segment = port, remainder of path = upstream (includes former exp+sig+rest) + assert.Equal(t, "/"+exp+"/"+goodSig+"/extra/segment", h.requestURI) + }) + + t.Run("OSEP shape plus OpenSandbox-Secure-Access header", func(t *testing.T) { + path := fmt.Sprintf("/%s/3000/%s/%s/api", testIDNoAccess, exp, goodSig) + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "noise") + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "/"+exp+"/"+goodSig+"/api", h.requestURI) + }) +} + +// --- Access required (GetEndpoint: non-empty SecureAccessToken) --- + +func TestMatrix_AccessRequired_Header(t *testing.T) { + prov := newTestProvider() + secret := []byte{0x70, 0x71, 0x72, 0x73} + exp := strconv.FormatUint(uint64(time.Now().Add(2*time.Hour).Unix()), 36) + goodSig := makeRouteSig(t, secret, "k", testIDSecure, 7777, exp) + badHMAC := "aabbccdd" + "k" // 9 hex chars, valid format but wrong digest + + ver := &signature.Verifier{Keys: map[string][]byte{"k": secret}} + p := &Proxy{mode: ModeHeader, sandboxProvider: prov, secure: ver} + + t.Run("legacy two segment no header no signature -> 401", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDSecure + "-7777.gw" + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrSignatureRequired) + }) + + t.Run("legacy two segment with correct OpenSandbox-Secure-Access only", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDSecure + "-7777.gw" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, testToken) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDSecure, h.ingressKey) + assert.Equal(t, 7777, h.port) + }) + + t.Run("legacy two segment wrong OpenSandbox-Secure-Access", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDSecure + "-7777.gw" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "bad-token") + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrSecureHeaderMismatch) + }) + + t.Run("legacy header field present with empty value counts as present (401 if token required)", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = testIDSecure + "-7777.gw" + r.Header[signature.OpenSandboxSecureAccessCanonical] = []string{""} + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrSecureHeaderMismatch) + }) + + t.Run("signed host valid HMAC no header", func(t *testing.T) { + label := fmt.Sprintf("%s-7777-%s-%s", testIDSecure, exp, goodSig) + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = label + ".gw" + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDSecure, h.ingressKey) + assert.Equal(t, 7777, h.port) + }) + + t.Run("signed host valid HMAC but wrong OpenSandbox-Secure-Access is fail fast (no fallthrough)", func(t *testing.T) { + label := fmt.Sprintf("%s-7777-0-%s", testIDSecure, goodSig) + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = label + ".gw" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "nope") + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrSecureHeaderMismatch) + }) + + t.Run("signed host bad HMAC no header", func(t *testing.T) { + label := fmt.Sprintf("%s-7777-%s-%s", testIDSecure, exp, badHMAC) + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = label + ".gw" + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrUnauthorized) + }) + + t.Run("signed host bad HMAC with correct OpenSandbox-Secure-Access passes without verifying HMAC", func(t *testing.T) { + label := fmt.Sprintf("%s-7777-%s-%s", testIDSecure, exp, badHMAC) + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = label + ".gw" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, testToken) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDSecure, h.ingressKey) + }) + + t.Run("signed host valid HMAC with verifier nil -> 503", func(t *testing.T) { + label := fmt.Sprintf("%s-7777-%s-%s", testIDSecure, exp, goodSig) + r := httptest.NewRequest(http.MethodGet, "http://x/", nil) + r.Host = label + ".gw" + pNoV := &Proxy{mode: ModeHeader, sandboxProvider: prov, secure: nil} + _, code, err := pNoV.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusServiceUnavailable, code) + assert.ErrorIs(t, err, signature.ErrVerifierNotConfigured) + }) +} + +func TestMatrix_AccessRequired_URI(t *testing.T) { + prov := newTestProvider() + secret := []byte{0x9a, 0x9b, 0x9c, 0x9d} + exp := strconv.FormatUint(uint64(time.Now().Add(2*time.Hour).Unix()), 36) + goodSig := makeRouteSig(t, secret, "a", testIDSecure, 5000, exp) + badHMAC := "badbadba" + "a" + + ver := &signature.Verifier{Keys: map[string][]byte{"a": secret}} + p := &Proxy{mode: ModeURI, sandboxProvider: prov, secure: ver} + + orep := func() string { return fmt.Sprintf("/%s/5000/%s/%s", testIDSecure, exp, goodSig) } + + t.Run("legacy two segment no credentials -> 401", func(t *testing.T) { + path := "/" + testIDSecure + "/5000/only/legacy" + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrSignatureRequired) + }) + + t.Run("legacy with correct OpenSandbox-Secure-Access only", func(t *testing.T) { + path := "/" + testIDSecure + "/5000/only/legacy" + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, testToken) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDSecure, h.ingressKey) + assert.Equal(t, 5000, h.port) + assert.Equal(t, "/only/legacy", h.requestURI) + }) + + t.Run("signed URI valid HMAC no header", func(t *testing.T) { + path := orep() + "/p/q" + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "/p/q", h.requestURI) + assert.Equal(t, testIDSecure, h.ingressKey) + assert.Equal(t, 5000, h.port) + }) + + t.Run("signed URI valid HMAC wrong OpenSandbox-Secure-Access fail fast", func(t *testing.T) { + path := orep() + "/x" + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "wrong") + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrSecureHeaderMismatch) + }) + + t.Run("signed URI bad HMAC no header", func(t *testing.T) { + path := fmt.Sprintf("/%s/5000/%s/%s/ok", testIDSecure, exp, badHMAC) + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + _, code, err := p.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorIs(t, err, signature.ErrUnauthorized) + }) + + t.Run("signed URI bad HMAC with good header (header path wins)", func(t *testing.T) { + path := fmt.Sprintf("/%s/5000/%s/%s/ok", testIDSecure, exp, badHMAC) + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, testToken) + h, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, testIDSecure, h.ingressKey) + }) + + t.Run("signed URI valid HMAC with verifier nil -> 503", func(t *testing.T) { + path := orep() + "/m" + r := httptest.NewRequest(http.MethodGet, "http://i"+path, nil) + pNoV := &Proxy{mode: ModeURI, sandboxProvider: prov, secure: nil} + _, code, err := pNoV.getSandboxHostDefinition(r) + assert.Error(t, err) + assert.Equal(t, http.StatusServiceUnavailable, code) + assert.ErrorIs(t, err, signature.ErrVerifierNotConfigured) + }) +} diff --git a/components/ingress/pkg/proxy/host_route_parse.go b/components/ingress/pkg/proxy/host_route_parse.go new file mode 100644 index 0000000..6c1e766 --- /dev/null +++ b/components/ingress/pkg/proxy/host_route_parse.go @@ -0,0 +1,124 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/alibaba/opensandbox/ingress/pkg/signature" +) + +type parsedRoute struct { + sandboxID string + port int + expiresB36 string + signature string + requestURI string + uriParsedAsOSEP bool +} + +func parseHostRoute(s string) (parsedRoute, error) { + domain := strings.Split(strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://"), ".") + if len(domain) < 1 { + return parsedRoute{}, fmt.Errorf("invalid host: %s", s) + } + label := domain[0] + + sandboxID, port, expires, routeSig, parseErr := signature.ParseRouteToken(label) + if parseErr == nil { + return parsedRoute{ + sandboxID: sandboxID, + port: port, + expiresB36: expires, + signature: routeSig, + requestURI: "", + }, nil + } + + ingressAndPort := strings.Split(label, "-") + if len(ingressAndPort) <= 1 || ingressAndPort[0] == "" { + return parsedRoute{}, fmt.Errorf("invalid host: %s", s) + } + ingress := strings.Join(ingressAndPort[:len(ingressAndPort)-1], "-") + port, err := strconv.Atoi(ingressAndPort[len(ingressAndPort)-1]) + if err != nil { + return parsedRoute{}, fmt.Errorf("invalid port format: %w", err) + } + return parsedRoute{sandboxID: ingress, port: port, expiresB36: "", signature: "", requestURI: ""}, nil +} + +func parseURIRoute(path string) (parsedRoute, error) { + if path == "" { + return parsedRoute{}, errors.New("missing URI path") + } + + trimmed := strings.TrimPrefix(path, "/") + parts := strings.SplitN(trimmed, "/", 5) + if len(parts) >= 4 && parts[0] != "" { + port, perr := signature.ParsePortSegment(parts[1]) + if perr == nil { + if _, eerr := signature.ParseExpiresB36(parts[2]); eerr == nil { + if signature.ValidateSignatureFormat(parts[3]) == nil { + requestURI := "/" + if len(parts) == 5 && parts[4] != "" { + requestURI = "/" + parts[4] + } + return parsedRoute{ + sandboxID: parts[0], + port: port, + expiresB36: parts[2], + signature: parts[3], + requestURI: requestURI, + uriParsedAsOSEP: true, + }, nil + } + } + } + } + return parseURILegacy(path) +} + +func parseURILegacy(path string) (parsedRoute, error) { + trimmed := strings.TrimPrefix(path, "/") + parts := strings.SplitN(trimmed, "/", 3) + if len(parts) < 2 { + return parsedRoute{}, fmt.Errorf("invalid URI path format: expected '///', got: %s", path) + } + sandboxID := parts[0] + if sandboxID == "" { + return parsedRoute{}, errors.New("missing sandbox-id or sandbox-port in URI path") + } + port, err := signature.ParsePortSegment(parts[1]) + if err != nil { + return parsedRoute{}, fmt.Errorf("invalid port format: %w", err) + } + var requestURI string + if len(parts) >= 3 && parts[2] != "" { + requestURI = "/" + parts[2] + } else { + requestURI = "/" + } + return parsedRoute{ + sandboxID: sandboxID, + port: port, + expiresB36: "", + signature: "", + requestURI: requestURI, + uriParsedAsOSEP: false, + }, nil +} diff --git a/components/ingress/pkg/proxy/host_route_parse_test.go b/components/ingress/pkg/proxy/host_route_parse_test.go new file mode 100644 index 0000000..e768a48 --- /dev/null +++ b/components/ingress/pkg/proxy/host_route_parse_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseURIRoute_TwoSegmentsNoSignature(t *testing.T) { + pr, err := parseURIRoute("/my-sandbox/8080") + assert.NoError(t, err) + assert.Equal(t, "my-sandbox", pr.sandboxID) + assert.Equal(t, 8080, pr.port) + assert.Equal(t, "", pr.signature) + assert.Equal(t, "/", pr.requestURI) + assert.False(t, pr.uriParsedAsOSEP) +} + +func TestParseURIRoute_LegacyLeadingZeroPortRejected(t *testing.T) { + _, err := parseURIRoute("/sb/08080") + assert.Error(t, err) +} + +func TestParseURIRoute_OSEPFourSegmentsWithSig(t *testing.T) { + exp, sig := "1a2b3c", "01234567a" + pr, err := parseURIRoute("/sb/9090/" + exp + "/" + sig + "/extra/path") + assert.NoError(t, err) + assert.Equal(t, "sb", pr.sandboxID) + assert.Equal(t, 9090, pr.port) + assert.Equal(t, exp, pr.expiresB36) + assert.Equal(t, sig, pr.signature) + assert.Equal(t, "/extra/path", pr.requestURI) + assert.True(t, pr.uriParsedAsOSEP) +} diff --git a/components/ingress/pkg/proxy/host_secure_test.go b/components/ingress/pkg/proxy/host_secure_test.go new file mode 100644 index 0000000..b311fc1 --- /dev/null +++ b/components/ingress/pkg/proxy/host_secure_test.go @@ -0,0 +1,134 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/alibaba/opensandbox/ingress/pkg/signature" + "github.com/stretchr/testify/assert" +) + +// routeTestProvider stubs sandbox lookup for routing tests (non-empty access-token annotation). +type routeTestProvider struct{} + +func (routeTestProvider) GetEndpoint(string) (*sandbox.EndpointInfo, error) { + return &sandbox.EndpointInfo{ + Endpoint: "127.0.0.1", + SecureAccessToken: "annot-secret", + }, nil +} + +func (routeTestProvider) Start(context.Context) error { return nil } + +func TestGetSandboxHostDefinition_HeaderSecureSig(t *testing.T) { + secret := []byte("ingress-test-secret") + sb := "gamma" + port := 7777 + e := strconv.FormatUint(uint64(time.Now().Add(1*time.Hour).Unix()), 36) + sig := signature.ExpectedHex8(signature.Inner(secret, signature.CanonicalBytes(sb, port, e))) + "k" + + p := &Proxy{ + mode: ModeHeader, + sandboxProvider: routeTestProvider{}, + secure: &signature.Verifier{Keys: map[string][]byte{"k": secret}}, + } + label := fmt.Sprintf("%s-%d-%s-%s", sb, port, e, sig) + r := httptest.NewRequest(http.MethodGet, "http://example/", nil) + r.Host = label + ".gw.example.com" + host, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, sb, host.ingressKey) + assert.Equal(t, port, host.port) +} + +func TestGetSandboxHostDefinition_HeaderSecureHeaderBypassSignedShape(t *testing.T) { + sb := "gamma" + port := 7777 + // 9 characters: 8-hex + 1 key id (header bypasses HMAC, but must parse as OSEP-0011) + sig := "aabbccdd" + "1" + e := "0" + label := fmt.Sprintf("%s-%d-%s-%s", sb, port, e, sig) + + p := &Proxy{mode: ModeHeader, sandboxProvider: routeTestProvider{}} + r := httptest.NewRequest(http.MethodGet, "http://example/", nil) + r.Host = label + ".gw.example.com" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "annot-secret") + host, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, sb, host.ingressKey) + assert.Equal(t, port, host.port) +} + +func TestGetSandboxHostDefinition_HeaderSecureHeaderBypassLegacy(t *testing.T) { + p := &Proxy{mode: ModeHeader, sandboxProvider: routeTestProvider{}} + r := httptest.NewRequest(http.MethodGet, "http://example/", nil) + r.Host = "mysb-9090.example.com" + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "annot-secret") + host, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, "mysb", host.ingressKey) + assert.Equal(t, 9090, host.port) +} + +func TestGetSandboxHostDefinition_URISecureSig(t *testing.T) { + secret := []byte("uri-mode-secret") + sb := "d-e" + port := 3000 + e := strconv.FormatUint(uint64(time.Now().Add(1*time.Hour).Unix()), 36) + sig := signature.ExpectedHex8(signature.Inner(secret, signature.CanonicalBytes(sb, port, e))) + "a" + + p := &Proxy{ + mode: ModeURI, + sandboxProvider: routeTestProvider{}, + secure: &signature.Verifier{Keys: map[string][]byte{"a": secret}}, + } + path := fmt.Sprintf("/%s/%d/%s/%s/api/x", sb, port, e, sig) + r := httptest.NewRequest(http.MethodGet, "http://ingress.local"+path, nil) + host, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, sb, host.ingressKey) + assert.Equal(t, port, host.port) + assert.Equal(t, "/api/x", host.requestURI) +} + +func TestGetSandboxHostDefinition_URISecureHeaderBypass(t *testing.T) { + sb := "d-e" + port := 3000 + e := "0" + sig := "cafebabe" + "0" + + p := &Proxy{mode: ModeURI, sandboxProvider: routeTestProvider{}} + path := fmt.Sprintf("/%s/%d/%s/%s/api/x", sb, port, e, sig) + r := httptest.NewRequest(http.MethodGet, "http://ingress.local"+path, nil) + r.Header.Set(signature.OpenSandboxSecureAccessCanonical, "annot-secret") + host, code, err := p.getSandboxHostDefinition(r) + assert.NoError(t, err) + assert.Equal(t, 0, code) + assert.Equal(t, sb, host.ingressKey) + assert.Equal(t, port, host.port) + assert.Equal(t, "/api/x", host.requestURI) +} diff --git a/components/ingress/pkg/proxy/http.go b/components/ingress/pkg/proxy/http.go new file mode 100644 index 0000000..d764c74 --- /dev/null +++ b/components/ingress/pkg/proxy/http.go @@ -0,0 +1,59 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "net/http" + "net/http/httputil" + "net/url" +) + +type HTTPProxy struct{} + +func NewHTTPProxy() *HTTPProxy { + return &HTTPProxy{} +} + +func (hp *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + targetURL := r.URL.String() + + proxy, err := hp.newReverseProxy(targetURL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + proxy.ServeHTTP(w, r) +} + +func (hp *HTTPProxy) newReverseProxy(targetHost string) (*httputil.ReverseProxy, error) { + url, err := url.Parse(targetHost) + if err != nil { + return nil, err + } + + proxy := httputil.NewSingleHostReverseProxy(url) + proxy.Director = func(req *http.Request) { + req.URL.Scheme = url.Scheme + req.URL.Host = url.Host + req.Host = url.Host + req.Header.Del(SandboxIngress) + } + proxy.ModifyResponse = func(response *http.Response) error { + response.Header.Add(ReverseProxyServerPowerBy, "OpenSandbox-ingress") + return nil + } + return proxy, nil +} diff --git a/components/ingress/pkg/proxy/http_test.go b/components/ingress/pkg/proxy/http_test.go new file mode 100644 index 0000000..777502a --- /dev/null +++ b/components/ingress/pkg/proxy/http_test.go @@ -0,0 +1,229 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/stretchr/testify/assert" +) + +// mockProvider implements sandbox.Provider interface for testing +type mockProvider struct { + endpoints map[string]string // sandboxName -> IP + notReady map[string]bool // sandboxName -> notReady flag + accessToken map[string]string // sandboxName -> opensandbox.io/secure-access-token value (non-empty => verification required) +} + +func (m *mockProvider) sandboxExists(sandboxId string) bool { + if m.notReady != nil && m.notReady[sandboxId] { + return true + } + _, ok := m.endpoints[sandboxId] + return ok +} + +func (m *mockProvider) GetEndpoint(sandboxId string) (*sandbox.EndpointInfo, error) { + if m.notReady != nil && m.notReady[sandboxId] { + return nil, fmt.Errorf("%w: %s", sandbox.ErrSandboxNotReady, sandboxId) + } + if !m.sandboxExists(sandboxId) { + return nil, fmt.Errorf("%w: %s", sandbox.ErrSandboxNotFound, sandboxId) + } + ip := m.endpoints[sandboxId] + token := "" + if m.accessToken != nil { + token = strings.TrimSpace(m.accessToken[sandboxId]) + } + if ip == "" { + return nil, fmt.Errorf("%w: %s", sandbox.ErrSandboxNotFound, sandboxId) + } + return &sandbox.EndpointInfo{ + Endpoint: ip, + SecureAccessToken: token, + }, nil +} + +func (m *mockProvider) Start(_ context.Context) error { + return nil +} + +func Test_HTTPProxy(t *testing.T) { + t.Run("with header mode", func(t *testing.T) { + httpProxyWithHeaderMode(t) + }) + + t.Run("with uri mode", func(t *testing.T) { + httpProxyWithURIMode(t) + }) +} + +func httpProxyWithHeaderMode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(realBackendHTTPHandler)) + defer server.Close() + serverPort := server.URL[len("http://127.0.0.1:"):] + + // Create mock provider with sandbox endpoint + provider := &mockProvider{ + endpoints: map[string]string{ + "test-sandbox": "127.0.0.1", + }, + } + + ctx := context.Background() + Logger = slogger.MustNew(slogger.Config{Level: "debug"}) + proxy := NewProxy(ctx, provider, ModeHeader, nil, nil) + + mux := http.NewServeMux() + mux.Handle("/", proxy) + port, err := findAvailablePort() + assert.Nil(t, err) + + go func() { + assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(port), mux)) + }() + + time.Sleep(2 * time.Second) + + // no header + request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello", port), nil) + assert.Nil(t, err) + response, err := http.DefaultClient.Do(request) + assert.Nil(t, err) + assert.Equal(t, http.StatusBadRequest, response.StatusCode) + bytes, _ := io.ReadAll(response.Body) + t.Log(string(bytes)) + + // no sandbox backend + request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello", port), nil) + request.Header.Set(SandboxIngress, fmt.Sprintf("non-existent-%v", port)) + response, err = http.DefaultClient.Do(request) + assert.Nil(t, err) + assert.Equal(t, http.StatusNotFound, response.StatusCode) // ErrSandboxNotFound -> 404 + bytes, _ = io.ReadAll(response.Body) + t.Log(string(bytes)) + + // valid sandbox request + request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello?a=1&b=2", port), nil) + assert.Nil(t, err) + + request.Header.Set(SandboxIngress, fmt.Sprintf("test-sandbox-%v", serverPort)) + response, err = http.DefaultClient.Do(request) + assert.Nil(t, err) + if response.StatusCode != http.StatusOK { + bytes, err := io.ReadAll(response.Body) + assert.Nil(t, err) + t.Log(string(bytes)) + } + assert.Equal(t, http.StatusOK, response.StatusCode) + + // Compatible Host parsing for reverse proxy mode + request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello?a=1&b=2", port), nil) + assert.Nil(t, err) + + request.Host = fmt.Sprintf("test-sandbox-%v.sandbox.alibaba-inc.com", serverPort) + response, err = http.DefaultClient.Do(request) + assert.Nil(t, err) + if response.StatusCode != http.StatusOK { + bytes, err := io.ReadAll(response.Body) + assert.Nil(t, err) + t.Log(string(bytes)) + } + assert.Equal(t, http.StatusOK, response.StatusCode) +} + +func httpProxyWithURIMode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(realBackendHTTPHandler)) + defer server.Close() + serverPort := server.URL[len("http://127.0.0.1:"):] + + // Create mock provider with sandbox endpoint + provider := &mockProvider{ + endpoints: map[string]string{ + "test-sandbox": "127.0.0.1", + }, + } + + ctx := context.Background() + Logger = slogger.MustNew(slogger.Config{Level: "debug"}) + proxy := NewProxy(ctx, provider, ModeURI, nil, nil) + + mux := http.NewServeMux() + mux.Handle("/", proxy) + port, err := findAvailablePort() + assert.Nil(t, err) + + go func() { + assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(port), mux)) + }() + + time.Sleep(2 * time.Second) + + // uri is empty + request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v", port), nil) + assert.Nil(t, err) + response, err := http.DefaultClient.Do(request) + assert.Nil(t, err) + assert.Equal(t, http.StatusBadRequest, response.StatusCode) + bytes, _ := io.ReadAll(response.Body) + t.Log(string(bytes)) + + // no sandbox backend + request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/non-existent-xxx/80/hello", port), nil) + response, err = http.DefaultClient.Do(request) + assert.Nil(t, err) + assert.Equal(t, http.StatusNotFound, response.StatusCode) // ErrSandboxNotFound -> 404 + bytes, _ = io.ReadAll(response.Body) + t.Log(string(bytes)) + + // valid sandbox request + request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/test-sandbox/%v/hello?a=1&b=2", port, serverPort), nil) + assert.Nil(t, err) + response, err = http.DefaultClient.Do(request) + assert.Nil(t, err) + if response.StatusCode != http.StatusOK { + bytes, err := io.ReadAll(response.Body) + assert.Nil(t, err) + t.Log(string(bytes)) + } + assert.Equal(t, http.StatusOK, response.StatusCode) +} + +func realBackendHTTPHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + if r.URL.Path != "/hello" { + http.Error(w, fmt.Sprintf("path is not /hello, but %s", r.URL.Path), http.StatusBadRequest) + } + if r.URL.RawQuery != "a=1&b=2" { + http.Error(w, fmt.Sprintf("query is not a=1&b=2, but %s", r.URL.RawQuery), http.StatusBadRequest) + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("hello world")) +} diff --git a/components/ingress/pkg/proxy/logger.go b/components/ingress/pkg/proxy/logger.go new file mode 100644 index 0000000..b312759 --- /dev/null +++ b/components/ingress/pkg/proxy/logger.go @@ -0,0 +1,28 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "context" + + slogger "github.com/alibaba/opensandbox/internal/logger" +) + +var Logger slogger.Logger + +func WithLogger(ctx context.Context, logger slogger.Logger) context.Context { + Logger = logger + return ctx +} diff --git a/components/ingress/pkg/proxy/proxy.go b/components/ingress/pkg/proxy/proxy.go new file mode 100644 index 0000000..74adcb3 --- /dev/null +++ b/components/ingress/pkg/proxy/proxy.go @@ -0,0 +1,232 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/alibaba/opensandbox/ingress/pkg/renewintent" + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/alibaba/opensandbox/ingress/pkg/signature" + "github.com/alibaba/opensandbox/ingress/pkg/telemetry" + slogger "github.com/alibaba/opensandbox/internal/logger" +) + +type Proxy struct { + sandboxProvider sandbox.Provider + mode Mode + renewIntentPublisher renewintent.Publisher + + secure *signature.Verifier +} + +func NewProxy(_ context.Context, sandboxProvider sandbox.Provider, mode Mode, renewIntentPublisher renewintent.Publisher, secure *signature.Verifier) *Proxy { + return &Proxy{ + sandboxProvider: sandboxProvider, + mode: mode, + renewIntentPublisher: renewIntentPublisher, + secure: secure, + } +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusCapturingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK} + proxyType := "http" + if p.isWebSocketRequest(r) { + proxyType = "websocket" + } + + defer func() { + if rcv := recover(); rcv != nil { + panicErr := fmt.Sprintf("%v", rcv) + if err, ok := rcv.(error); ok { + panicErr = err.Error() + } + Logger.With( + slogger.Field{Key: "error", Value: panicErr}, + slogger.Field{Key: "uri", Value: r.RequestURI}, + slogger.Field{Key: "host", Value: r.Host}, + slogger.Field{Key: "method", Value: r.Method}, + ).Errorf("ingress: proxy causes panic") + sw.WriteHeader(http.StatusBadGateway) + http.Error(sw, panicErr, http.StatusBadGateway) + } + telemetry.RecordHTTPRequest(r.Method, sw.statusCode, proxyType, float64(time.Since(start))/float64(time.Millisecond)) + }() + + host, status, err := p.getSandboxHostDefinition(r) + if err != nil { + if status == 0 { + status = http.StatusBadRequest + } + http.Error(sw, fmt.Sprintf("OpenSandbox Ingress: %v", err), status) + return + } + + targetHost, err, code := p.resolveRealHost(host) + if err != nil { + http.Error(sw, fmt.Sprintf("OpenSandbox Ingress: %v", err), code) + return + } + + if p.renewIntentPublisher != nil { + p.renewIntentPublisher.PublishIntent(host.ingressKey, host.port, host.requestURI) + } + + // modify if requestURI is not empty + if host.requestURI != "" { + r.URL.Path = host.requestURI + } + + r.Host = targetHost + r.URL.Host = targetHost + r.Header.Del(SandboxIngress) + r.Header.Del(signature.OpenSandboxSecureAccessCanonical) + + Logger.With( + slogger.Field{Key: "target", Value: targetHost}, + slogger.Field{Key: "client", Value: p.getClientIP(r)}, + slogger.Field{Key: "uri", Value: r.RequestURI}, + slogger.Field{Key: "method", Value: r.Method}, + ).Infof("ingress requested") + p.serve(sw, r) +} + +func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) { + if p.isWebSocketRequest(r) { + if r.URL == nil { + http.Error(w, "invalid request URL", http.StatusBadRequest) + return + } + + if r.URL.Scheme == "" { + if r.TLS != nil { + r.URL.Scheme = "wss" + } else { + r.URL.Scheme = "ws" + } + } + NewWebSocketProxy(r.URL).ServeHTTP(w, r) + } else { + if r.URL.Scheme == "" { + if r.TLS != nil { + r.URL.Scheme = "https" + } else { + r.URL.Scheme = "http" + } + } + NewHTTPProxy().ServeHTTP(w, r) + } +} + +func (p *Proxy) isWebSocketRequest(r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + if r.Header.Get("Upgrade") != "websocket" { + return false + } + if r.Header.Get("Connection") != "Upgrade" { + return false + } + return true +} + +func (p *Proxy) resolveRealHost(host *sandboxHost) (string, error, int) { + endpoint := host.endpoint + if endpoint == "" { + // Fallback lookup (should rarely happen because host parsing now fills endpoint). + info, err := p.sandboxProvider.GetEndpoint(host.ingressKey) + if err != nil { + // Map sandbox errors to HTTP status codes + switch { + case errors.Is(err, sandbox.ErrSandboxNotFound): + return "", err, http.StatusNotFound + case errors.Is(err, sandbox.ErrSandboxNotReady): + return "", err, http.StatusServiceUnavailable + default: + return "", err, http.StatusBadGateway + } + } + endpoint = info.Endpoint + } + + // Construct target host with port + targetHost := fmt.Sprintf("%s:%d", endpoint, host.port) + return targetHost, nil, 0 +} + +type statusCapturingResponseWriter struct { + http.ResponseWriter + statusCode int + written bool +} + +func (w *statusCapturingResponseWriter) WriteHeader(code int) { + if !w.written { + w.statusCode = code + w.written = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusCapturingResponseWriter) Write(b []byte) (int, error) { + if !w.written { + w.written = true + } + return w.ResponseWriter.Write(b) +} + +func (w *statusCapturingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := w.ResponseWriter.(http.Hijacker); ok { + conn, buf, err := hj.Hijack() + if err == nil && !w.written { + w.statusCode = http.StatusSwitchingProtocols + w.written = true + } + return conn, buf, err + } + return nil, nil, fmt.Errorf("upstream ResponseWriter does not implement http.Hijacker") +} + +func (w *statusCapturingResponseWriter) Flush() { + if fl, ok := w.ResponseWriter.(http.Flusher); ok { + fl.Flush() + } +} + +func (p *Proxy) getClientIP(r *http.Request) string { + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + if len(r.Header.Get(XForwardedFor)) != 0 { + xff := r.Header.Get(XForwardedFor) + s := strings.Index(xff, ", ") + if s == -1 { + s = len(r.Header.Get(XForwardedFor)) + } + clientIP = xff[:s] + } else if len(r.Header.Get(XRealIP)) != 0 { + clientIP = r.Header.Get(XRealIP) + } + + return clientIP +} diff --git a/components/ingress/pkg/proxy/proxy_test.go b/components/ingress/pkg/proxy/proxy_test.go new file mode 100644 index 0000000..2b8ced1 --- /dev/null +++ b/components/ingress/pkg/proxy/proxy_test.go @@ -0,0 +1,104 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alibaba/opensandbox/ingress/pkg/sandbox" + "github.com/stretchr/testify/assert" +) + +// stubNoSecureProvider is a minimal sandbox.Provider for unit tests (no access-token / secure routing). +type stubNoSecureProvider struct{} + +func (stubNoSecureProvider) GetEndpoint(string) (*sandbox.EndpointInfo, error) { + return &sandbox.EndpointInfo{Endpoint: "127.0.0.1"}, nil +} + +func (stubNoSecureProvider) Start(context.Context) error { return nil } + +// Test_WatchPods is removed as we now use BatchSandbox Provider instead of direct Pod watching + +func TestIsWebSocketRequest(t *testing.T) { + proxy := &Proxy{} + + // Valid websocket request + req := httptest.NewRequest(http.MethodGet, "/ws", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + assert.True(t, proxy.isWebSocketRequest(req)) + + // Missing upgrade headers + req = httptest.NewRequest(http.MethodGet, "/ws", nil) + assert.False(t, proxy.isWebSocketRequest(req)) + + // Wrong method + req = httptest.NewRequest(http.MethodPost, "/ws", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + assert.False(t, proxy.isWebSocketRequest(req)) +} + +func TestParseHostRoute(t *testing.T) { + pr, err := parseHostRoute("sandbox-1234.example.com") + assert.NoError(t, err) + assert.Equal(t, "sandbox", pr.sandboxID) + assert.Equal(t, 1234, pr.port) + + pr, err = parseHostRoute("https://alpha-beta-8080.sandbox.test") + assert.NoError(t, err) + assert.Equal(t, "alpha-beta", pr.sandboxID) + assert.Equal(t, 8080, pr.port) + + _, err = parseHostRoute("invalidhost") + assert.Error(t, err) + + _, err = parseHostRoute("-1234.example.com") + assert.Error(t, err) +} + +func TestGetClientIP(t *testing.T) { + proxy := &Proxy{} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:12345" + assert.Equal(t, "192.0.2.1", proxy.getClientIP(req)) + + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:12345" + req.Header.Set(XRealIP, "203.0.113.5") + assert.Equal(t, "203.0.113.5", proxy.getClientIP(req)) + + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.0.2.1:12345" + req.Header.Set(XForwardedFor, "10.0.0.1, 198.51.100.2") + assert.Equal(t, "10.0.0.1", proxy.getClientIP(req)) +} + +func findAvailablePort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + return port, nil +} diff --git a/components/ingress/pkg/proxy/websocket.go b/components/ingress/pkg/proxy/websocket.go new file mode 100644 index 0000000..6a94516 --- /dev/null +++ b/components/ingress/pkg/proxy/websocket.go @@ -0,0 +1,257 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/gorilla/websocket" +) + +var ( + // defaultWebSocketDialer is a dialer with all fields set to the default zero values. + defaultWebSocketDialer = websocket.DefaultDialer + + // defaultUpgrader specifies the parameters for upgrading an HTTP + // connection to a WebSocket connection. + defaultUpgrader = &websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // Allow any Origin: ingress sits behind trusted gateways where Host/Origin + // often diverge (e.g. browser UI vs internal target). gorilla's default + // same-origin check rejects those upgrades. + CheckOrigin: func(_ *http.Request) bool { return true }, + } +) + +// WebSocketProxy is an HTTP Handler that takes an incoming WebSocket +// connection and proxies it to another server. +type WebSocketProxy struct { + // director, if non-nil, is a function that may copy additional request + // headers from the incoming WebSocket connection into the output headers + // which will be forwarded to another server. + director func(incoming *http.Request, out http.Header) + + // backend returns the backend URL which the proxy uses to reverse proxy + // the incoming WebSocket connection. Request is the initial incoming and + // unmodified request. + backend func(*http.Request) *url.URL + + // dialer contains options for connecting to the backend WebSocket server. + // If nil, DefaultDialer is used. + dialer *websocket.Dialer + + // upgrader specifies the parameters for upgrading a incoming HTTP + // connection to a WebSocket connection. If nil, DefaultUpgrader is used. + upgrader *websocket.Upgrader +} + +// ProxyHandler returns a new http.Handler interface that reverse proxies the +// request to the given target. +func ProxyHandler(target *url.URL) http.Handler { return NewWebSocketProxy(target) } + +// NewWebSocketProxy returns a new Websocket reverse proxy that rewrites the +// URL's to the scheme, host and base path provider in target. +func NewWebSocketProxy(target *url.URL) *WebSocketProxy { + backend := func(r *http.Request) *url.URL { + // Shallow copy + u := *target + u.Fragment = r.URL.Fragment + u.Path = r.URL.Path + u.RawQuery = r.URL.RawQuery + return &u + } + return &WebSocketProxy{backend: backend} +} + +//nolint:gocognit +func (w *WebSocketProxy) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + if w.backend == nil { + http.Error(rw, "WebSocketProxy: backend is not defined", http.StatusInternalServerError) + return + } + + backendURL := w.backend(r) + if backendURL == nil { + http.Error(rw, "WebSocketProxy: backend URL is nil", http.StatusInternalServerError) + return + } + + dialer := w.dialer + if w.dialer == nil { + dialer = defaultWebSocketDialer + } + + // Forward all incoming headers to the backend except hop-by-hop headers + // (RFC 7230 §6.1) and WebSocket handshake headers managed by the dialer. + // Per RFC 7230, also strip any header named by Connection tokens. + connTokens := map[string]bool{} + for _, v := range r.Header[HopByHopConnection] { + for _, token := range strings.Split(v, ",") { + if h := http.CanonicalHeaderKey(strings.TrimSpace(token)); h != "" { + connTokens[h] = true + } + } + } + requestHeader := http.Header{} + for key, values := range r.Header { + switch key { + case HopByHopConnection, HopByHopKeepAlive, HopByHopProxyAuth, HopByHopProxyAuthz, + HopByHopTE, HopByHopTrailer, HopByHopTransferEncoding, HopByHopUpgrade, + HopByHopProxyConnection, SecWebSocketKey, SecWebSocketVersion, SecWebSocketExtensions: + continue + } + if connTokens[key] { + continue + } + for _, v := range values { + requestHeader.Add(key, v) + } + } + if r.Host != "" { + requestHeader.Set(Host, r.Host) + } + + // Pass X-Forwarded-For headers too, code below is a part of + // httputil.ReverseProxy. See http://en.wikipedia.org/wiki/X-Forwarded-For + // for more information + if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + // If we aren't the first proxy retain prior + // X-Forwarded-For information as a comma+space + // separated list and fold multiple headers into one. + if prior, ok := r.Header[XForwardedFor]; ok { + clientIP = strings.Join(prior, ", ") + ", " + clientIP + } + requestHeader.Set(XForwardedFor, clientIP) + } + + // Set the originating protocol of the incoming HTTP request. The SSL might + // be terminated on our site and because we doing proxy adding this would + // be helpful for applications on the backend. + requestHeader.Set(XForwardedProto, "http") + if r.TLS != nil { + requestHeader.Set(XForwardedProto, "https") + } + + // Enable the director to copy any additional headers it desires for + // forwarding to the remote server. + if w.director != nil { + w.director(r, requestHeader) + } + + // Connect to the backend URL, also pass the headers we get from the requst + // together with the Forwarded headers we prepared above. + connBackend, resp, err := dialer.Dial(backendURL.String(), requestHeader) + if err != nil { + Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("WebSocketProxy: couldn't dial to remote backend") + if resp != nil { + // If the WebSocket handshake fails, ErrBadHandshake is returned + // along with a non-nil *http.Response so that callers can handle + // redirects, authentication, etcetera. + if err := copyResponse(rw, resp); err != nil { + Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("WebSocketProxy: couldn't write response after failed remote backend handshake") + } + } else { + http.Error(rw, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) + } + return + } + defer connBackend.Close() + + upgrader := w.upgrader + if w.upgrader == nil { + upgrader = defaultUpgrader + } + + // Only pass those headers to the upgrader. + upgradeHeader := http.Header{} + if hdr := resp.Header.Get(SecWebSocketProtocol); hdr != "" { + upgradeHeader.Set(SecWebSocketProtocol, hdr) + } + if hdr := resp.Header.Get(SetCookie); hdr != "" { + upgradeHeader.Set(SetCookie, hdr) + } + + // Now upgrade the existing incoming request to a WebSocket connection. + // Also pass the header that we gathered from the Dial handshake. + connPub, err := upgrader.Upgrade(rw, r, upgradeHeader) + if err != nil { + Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("WebSocketProxy: couldn't upgrade websocket connection") + return + } + defer connPub.Close() + + errClient := make(chan error, 1) + errBackend := make(chan error, 1) + replicateWebsocketConn := func(dst, src *websocket.Conn, errc chan error) { + for { + msgType, msg, err := src.ReadMessage() + if err != nil { + m := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf("%v", err)) + if e, ok := err.(*websocket.CloseError); ok { //nolint:errorlint + if e.Code != websocket.CloseNoStatusReceived { + m = websocket.FormatCloseMessage(e.Code, e.Text) + } + } + errc <- err + _ = dst.WriteMessage(websocket.CloseMessage, m) + break + } + err = dst.WriteMessage(msgType, msg) + if err != nil { + errc <- err + break + } + } + } + + go replicateWebsocketConn(connPub, connBackend, errClient) + go replicateWebsocketConn(connBackend, connPub, errBackend) + + var message string + select { + case err = <-errClient: + message = "WebSocketProxy: Error when copying from backend to client: %v" + case err = <-errBackend: + message = "WebSocketProxy: Error when copying from client to backend: %v" + + } + if e, ok := err.(*websocket.CloseError); !ok || e.Code == websocket.CloseAbnormalClosure { //nolint:errorlint + Logger.With(slogger.Field{Key: "error", Value: err}).Errorf(message, err) + } +} + +func copyResponse(rw http.ResponseWriter, resp *http.Response) error { + copyHeader(rw.Header(), resp.Header) + rw.WriteHeader(resp.StatusCode) + defer resp.Body.Close() + + _, err := io.Copy(rw, resp.Body) + return err +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} diff --git a/components/ingress/pkg/proxy/websocket_test.go b/components/ingress/pkg/proxy/websocket_test.go new file mode 100644 index 0000000..0eb0a40 --- /dev/null +++ b/components/ingress/pkg/proxy/websocket_test.go @@ -0,0 +1,223 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package proxy + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "testing" + "time" + + slogger "github.com/alibaba/opensandbox/internal/logger" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" +) + +func Test_WebSocketProxy(t *testing.T) { + t.Run("with header mode", func(t *testing.T) { + webSocketProxyWithHeaderMode(t) + }) + t.Run("with uri mode", func(t *testing.T) { + webSocketProxyWithURIMode(t) + }) +} + +func webSocketProxyWithHeaderMode(t *testing.T) { + // Create mock provider + provider := &mockProvider{ + endpoints: map[string]string{ + "test-sandbox": "127.0.0.1", + }, + } + + ctx := context.Background() + Logger = slogger.MustNew(slogger.Config{Level: "debug"}) + proxy := NewProxy(ctx, provider, ModeHeader, nil, nil) + + mux := http.NewServeMux() + mux.Handle("/", proxy) + proxyPort, err := findAvailablePort() + proxyURL := "ws://127.0.0.1:" + strconv.Itoa(proxyPort) + assert.Nil(t, err) + + go func() { + assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(proxyPort), mux)) + }() + + time.Sleep(2 * time.Second) + + backendPort, err := findAvailablePort() + assert.Nil(t, err) + + // backend echo server + go func() { + mux2 := http.NewServeMux() + mux2.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + t.Logf("r.URL.Path: %s", r.URL.Path) + t.Logf("r.URL.RawPath: %s", r.URL.RawPath) + t.Logf("r.Host: %s", r.Host) + // Don't upgrade if original host header isn't preserved + assert.True(t, strings.HasPrefix(r.Host, "127.0.0.1")) + + conn, err := defaultUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + + messageType, p, err := conn.ReadMessage() + if err != nil { + return + } + + if err = conn.WriteMessage(messageType, p); err != nil { + return + } + }) + + err := http.ListenAndServe(":"+strconv.Itoa(backendPort), mux2) + if err != nil { + t.Error("ListenAndServe: ", err) + return + } + }() + + time.Sleep(time.Millisecond * 100) + + // frontend server, dial now our proxy, which will reverse proxy our + // message to the backend websocket server. + h := http.Header{} + h.Set(SandboxIngress, "test-sandbox-"+strconv.Itoa(backendPort)) + conn, _, err := websocket.DefaultDialer.Dial(proxyURL+"/ws", h) + if err != nil { + t.Fatal(err) + } + + // write a message and send it to the backend server + msg := "hello kite" + err = conn.WriteMessage(websocket.TextMessage, []byte(msg)) + if err != nil { + t.Error(err) + } + + messageType, p, err := conn.ReadMessage() + if err != nil { + t.Error(err) + } + + if messageType != websocket.TextMessage { + t.Error("incoming message type is not Text") + } + + if msg != string(p) { + t.Errorf("expecting: %s, got: %s", msg, string(p)) + } +} + +func webSocketProxyWithURIMode(t *testing.T) { + // Create mock provider + provider := &mockProvider{ + endpoints: map[string]string{ + "test-sandbox": "127.0.0.1", + }, + } + + ctx := context.Background() + Logger = slogger.MustNew(slogger.Config{Level: "debug"}) + proxy := NewProxy(ctx, provider, ModeURI, nil, nil) + + mux := http.NewServeMux() + mux.Handle("/", proxy) + proxyPort, err := findAvailablePort() + proxyURL := "ws://127.0.0.1:" + strconv.Itoa(proxyPort) + assert.Nil(t, err) + + go func() { + assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(proxyPort), mux)) + }() + + time.Sleep(2 * time.Second) + + backendPort, err := findAvailablePort() + assert.Nil(t, err) + + // backend echo server + go func() { + mux2 := http.NewServeMux() + mux2.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + t.Logf("r.URL.Path: %s", r.URL.Path) + t.Logf("r.URL.RawPath: %s", r.URL.RawPath) + t.Logf("r.Host: %s", r.Host) + // Don't upgrade if original host header isn't preserved + assert.True(t, strings.HasPrefix(r.Host, "127.0.0.1")) + + conn, err := defaultUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Println(err) + return + } + + messageType, p, err := conn.ReadMessage() + if err != nil { + return + } + + if err = conn.WriteMessage(messageType, p); err != nil { + return + } + }) + + err := http.ListenAndServe(":"+strconv.Itoa(backendPort), mux2) + if err != nil { + t.Error("ListenAndServe: ", err) + return + } + }() + + time.Sleep(time.Millisecond * 100) + + // frontend server, dial now our proxy, which will reverse proxy our + // message to the backend websocket server. + h := http.Header{} + h.Set(SandboxIngress, "test-sandbox-"+strconv.Itoa(backendPort)) + conn, _, err := websocket.DefaultDialer.Dial(proxyURL+fmt.Sprintf("/test-sandbox/%v", backendPort)+"/ws", h) + if err != nil { + t.Fatal(err) + } + + // write a message and send it to the backend server + msg := "hello kite" + err = conn.WriteMessage(websocket.TextMessage, []byte(msg)) + if err != nil { + t.Error(err) + } + + messageType, p, err := conn.ReadMessage() + if err != nil { + t.Error(err) + } + + if messageType != websocket.TextMessage { + t.Error("incoming message type is not Text") + } + + if msg != string(p) { + t.Errorf("expecting: %s, got: %s", msg, string(p)) + } +} diff --git a/components/ingress/pkg/renewintent/intent.go b/components/ingress/pkg/renewintent/intent.go new file mode 100644 index 0000000..f2477c7 --- /dev/null +++ b/components/ingress/pkg/renewintent/intent.go @@ -0,0 +1,33 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package renewintent + +import "time" + +type Intent struct { + SandboxID string `json:"sandbox_id"` + ObservedAt string `json:"observed_at"` + Port int `json:"port,omitempty"` + RequestURI string `json:"request_uri,omitempty"` +} + +func NewIntent(sandboxID string, port int, requestURI string) Intent { + return Intent{ + SandboxID: sandboxID, + ObservedAt: time.Now().UTC().Format(time.RFC3339Nano), + Port: port, + RequestURI: requestURI, + } +} diff --git a/components/ingress/pkg/renewintent/intent_test.go b/components/ingress/pkg/renewintent/intent_test.go new file mode 100644 index 0000000..71e9d1d --- /dev/null +++ b/components/ingress/pkg/renewintent/intent_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package renewintent + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewIntent(t *testing.T) { + intent := NewIntent("sb-123", 8080, "/api/foo") + assert.Equal(t, "sb-123", intent.SandboxID) + assert.Equal(t, 8080, intent.Port) + assert.Equal(t, "/api/foo", intent.RequestURI) + assert.NotEmpty(t, intent.ObservedAt) +} + +func TestIntent_JSONRoundTrip(t *testing.T) { + intent := NewIntent("my-sandbox", 80, "/") + data, err := json.Marshal(intent) + assert.NoError(t, err) + var decoded Intent + err = json.Unmarshal(data, &decoded) + assert.NoError(t, err) + assert.Equal(t, intent.SandboxID, decoded.SandboxID) + assert.Equal(t, intent.Port, decoded.Port) + assert.Equal(t, intent.RequestURI, decoded.RequestURI) +} + +func TestIntent_JSONHasRequiredFields(t *testing.T) { + intent := NewIntent("id", 0, "") + data, err := json.Marshal(intent) + assert.NoError(t, err) + var m map[string]interface{} + err = json.Unmarshal(data, &m) + assert.NoError(t, err) + for _, key := range []string{"sandbox_id", "observed_at"} { + _, ok := m[key] + assert.True(t, ok, "missing required JSON field %q", key) + } +} diff --git a/components/ingress/pkg/renewintent/publisher.go b/components/ingress/pkg/renewintent/publisher.go new file mode 100644 index 0000000..f2f60cf --- /dev/null +++ b/components/ingress/pkg/renewintent/publisher.go @@ -0,0 +1,19 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package renewintent + +type Publisher interface { + PublishIntent(sandboxID string, port int, requestURI string) +} diff --git a/components/ingress/pkg/renewintent/redis.go b/components/ingress/pkg/renewintent/redis.go new file mode 100644 index 0000000..04a9a34 --- /dev/null +++ b/components/ingress/pkg/renewintent/redis.go @@ -0,0 +1,163 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package renewintent + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/alibaba/opensandbox/internal/logger" + "github.com/redis/go-redis/v9" + "k8s.io/apimachinery/pkg/util/wait" +) + +const ( + redisOpTimeout = 5 * time.Second + publishWorkers = 4 + publishChanCap = 8192 +) + +type RedisPublisherConfig struct { + QueueKey string + QueueMaxLen int + MinInterval time.Duration + Logger logger.Logger +} + +type intentReq struct { + sandboxID string + port int + requestURI string +} + +type RedisPublisher struct { + client *redis.Client + cfg RedisPublisherConfig + lastSent sync.Map + ch chan intentReq + stopped atomic.Bool +} + +func NewRedisPublisher(ctx context.Context, client *redis.Client, cfg RedisPublisherConfig) *RedisPublisher { + p := &RedisPublisher{client: client, cfg: cfg, ch: make(chan intentReq, publishChanCap)} + for i := 0; i < publishWorkers; i++ { + go func() { + for { + select { + case req := <-p.ch: + p.doPublish(req.sandboxID, req.port, req.requestURI) + case <-ctx.Done(): + return + } + } + }() + } + + go func() { + <-ctx.Done() + p.stopped.Store(true) + }() + + if cfg.MinInterval > 0 { + go wait.UntilWithContext(ctx, p.runCleanupThrottle, cfg.MinInterval*2) + } + return p +} + +func (p *RedisPublisher) shouldSendIntent(sandboxID string) bool { + if p.cfg.MinInterval <= 0 { + return true + } + + now := time.Now() + prev, loaded := p.lastSent.LoadOrStore(sandboxID, now) + if !loaded { + return true + } + if now.Sub(prev.(time.Time)) < p.cfg.MinInterval { + return false + } + p.lastSent.Store(sandboxID, now) + return true +} + +func (p *RedisPublisher) PublishIntent(sandboxID string, port int, requestURI string) { + if p.stopped.Load() { + return + } + select { + case p.ch <- intentReq{sandboxID: sandboxID, port: port, requestURI: requestURI}: + default: + } +} + +func (p *RedisPublisher) doPublish(sandboxID string, port int, requestURI string) { + if !p.shouldSendIntent(sandboxID) { + return + } + + intent := NewIntent(sandboxID, port, requestURI) + payload, err := json.Marshal(intent) + if err != nil { + p.cfg.Logger.With(logger.Field{Key: "sandbox_id", Value: sandboxID}).Errorf("renewintent: marshal intent: %v", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout) + defer cancel() + pipe := p.client.Pipeline() + pipe.LPush(ctx, p.cfg.QueueKey, string(payload)) + if p.cfg.QueueMaxLen > 0 { + pipe.LTrim(ctx, p.cfg.QueueKey, 0, int64(p.cfg.QueueMaxLen-1)) + } + _, err = pipe.Exec(ctx) + if err != nil { + p.cfg.Logger.With( + logger.Field{Key: "sandbox_id", Value: sandboxID}, + logger.Field{Key: "queue_key", Value: p.cfg.QueueKey}, + logger.Field{Key: "error", Value: err}, + ).Errorf("renewintent: redis publish failed") + return + } + p.cfg.Logger.With( + logger.Field{Key: "sandbox_id", Value: sandboxID}, + logger.Field{Key: "queue_key", Value: p.cfg.QueueKey}, + ).Debugf("renewintent: published") +} + +func RedisClientFromDSN(dsn string) (*redis.Client, error) { + opts, err := redis.ParseURL(dsn) + if err != nil { + return nil, err + } + if opts == nil { + return nil, errors.New("renewintent: redis DSN produced nil options") + } + return redis.NewClient(opts), nil +} + +func (p *RedisPublisher) runCleanupThrottle(_ context.Context) { + cutoff := time.Now().Add(-p.cfg.MinInterval * 2) + p.lastSent.Range(func(key, value any) bool { + if value.(time.Time).Before(cutoff) { + p.lastSent.Delete(key) + } + return true + }) +} diff --git a/components/ingress/pkg/renewintent/redis_bench_test.go b/components/ingress/pkg/renewintent/redis_bench_test.go new file mode 100644 index 0000000..b7cb1c9 --- /dev/null +++ b/components/ingress/pkg/renewintent/redis_bench_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package renewintent + +import ( + "context" + "fmt" + "testing" + + "github.com/alibaba/opensandbox/internal/logger" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +type nopLogger struct{} + +func (nopLogger) Debugf(string, ...any) {} +func (nopLogger) Infof(string, ...any) {} +func (nopLogger) Warnf(string, ...any) {} +func (nopLogger) Errorf(string, ...any) {} +func (n nopLogger) With(...logger.Field) logger.Logger { return n } +func (n nopLogger) Named(string) logger.Logger { return n } +func (nopLogger) Sync() error { return nil } + +// Benchmarks use miniredis (in-memory Redis) so timing excludes real network I/O. + +func BenchmarkRedisPublisher_PublishIntent(b *testing.B) { + mr, err := miniredis.Run() + if err != nil { + b.Fatal(err) + } + defer mr.Close() + + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := RedisPublisherConfig{ + QueueKey: "opensandbox:renew:intent", + QueueMaxLen: 0, + MinInterval: 0, + Logger: nopLogger{}, + } + p := NewRedisPublisher(ctx, client, cfg) + + sandboxID := "bench-sandbox" + port := 8080 + requestURI := "/api/health" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.PublishIntent(sandboxID, port, requestURI) + } +} + +func BenchmarkRedisPublisher_PublishIntent_Throttled(b *testing.B) { + mr, err := miniredis.Run() + if err != nil { + b.Fatal(err) + } + defer mr.Close() + + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := RedisPublisherConfig{ + QueueKey: "opensandbox:renew:intent", + QueueMaxLen: 0, + MinInterval: 1 << 30, // large so throttle skips most + Logger: nopLogger{}, + } + p := NewRedisPublisher(ctx, client, cfg) + + sandboxID := "bench-sandbox" + port := 8080 + requestURI := "/api/health" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.PublishIntent(sandboxID, port, requestURI) + } +} + +func BenchmarkRedisPublisher_PublishIntent_ManySandboxes(b *testing.B) { + mr, err := miniredis.Run() + if err != nil { + b.Fatal(err) + } + defer mr.Close() + + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := RedisPublisherConfig{ + QueueKey: "opensandbox:renew:intent", + QueueMaxLen: 0, + MinInterval: 0, + Logger: nopLogger{}, + } + p := NewRedisPublisher(ctx, client, cfg) + + port := 8080 + requestURI := "/api/health" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sandboxID := fmt.Sprintf("sandbox-%d", i%1000) + p.PublishIntent(sandboxID, port, requestURI) + } +} diff --git a/components/ingress/pkg/sandbox/agent_sandbox_provider.go b/components/ingress/pkg/sandbox/agent_sandbox_provider.go new file mode 100644 index 0000000..523a4e9 --- /dev/null +++ b/components/ingress/pkg/sandbox/agent_sandbox_provider.go @@ -0,0 +1,277 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +const ( + agentSandboxGroup = "agents.x-k8s.io" + agentSandboxVersion = "v1alpha1" + agentSandboxResource = "sandboxes" + + agentSandboxConditionReady = "Ready" + agentSandboxNamePrefix = "sandbox" +) + +var ( + dns1035InvalidChars = regexp.MustCompile(`[^a-z0-9-]+`) + dns1035DuplicateHyphens = regexp.MustCompile(`-+`) +) + +type AgentSandboxProvider struct { + informerFactory dynamicinformer.DynamicSharedInformerFactory + informer cache.SharedIndexInformer + gvr schema.GroupVersionResource +} + +func NewAgentSandboxProvider(config *rest.Config, resyncPeriod time.Duration) *AgentSandboxProvider { + dyn, err := dynamic.NewForConfig(config) + if err != nil { + panic(fmt.Sprintf("failed to create dynamic client: %v", err)) + } + + return newAgentSandboxProviderWithClient(dyn, resyncPeriod) +} + +func newAgentSandboxProviderWithClient(dyn dynamic.Interface, resyncPeriod time.Duration) *AgentSandboxProvider { + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + + factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory( + dyn, + resyncPeriod, + metav1.NamespaceAll, + nil, // no extra list options + ) + + informer := factory.ForResource(gvr).Informer() + if err := informer.AddIndexers(cache.Indexers{ + sandboxNameIndex: func(obj any) ([]string, error) { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return []string{}, nil + } + return []string{u.GetName()}, nil + }, + }); err != nil { + panic(fmt.Sprintf("failed to add AgentSandbox indexer: %v", err)) + } + + return &AgentSandboxProvider{ + informerFactory: factory, + informer: informer, + gvr: gvr, + } +} + +func agentSandboxResourceName(sandboxId string) string { + return toDNS1035Label(sandboxId, agentSandboxNamePrefix) +} + +func toDNS1035Label(value, prefix string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = dns1035InvalidChars.ReplaceAllString(normalized, "-") + normalized = dns1035DuplicateHyphens.ReplaceAllString(normalized, "-") + normalized = strings.Trim(normalized, "-") + + hash := sha256.Sum256([]byte(value)) + suffix := hex.EncodeToString(hash[:])[:8] + + if normalized == "" { + normalized = prefix + "-" + suffix + } else if !startsWithLetter(normalized) { + normalized = prefix + "-" + normalized + } + + if len(normalized) > validation.DNS1035LabelMaxLength { + maxBase := validation.DNS1035LabelMaxLength - len(suffix) - 1 + base := normalized + if len(base) > maxBase { + base = base[:maxBase] + } + base = strings.Trim(base, "-") + if !startsWithLetter(base) { + base = prefix + } + normalized = base + "-" + suffix + } + + return strings.Trim(normalized, "-") +} + +func startsWithLetter(value string) bool { + if value == "" { + return false + } + first := value[0] + return first >= 'a' && first <= 'z' +} + +func legacyAgentSandboxName(sandboxId string) string { + legacyPrefix := agentSandboxNamePrefix + "-" + if strings.HasPrefix(sandboxId, legacyPrefix) { + return sandboxId + } + return legacyPrefix + sandboxId +} + +func resourceNameCandidates(sandboxId string) []string { + candidates := []string{} + primary := agentSandboxResourceName(sandboxId) + candidates = append(candidates, primary) + if sandboxId != primary { + candidates = append(candidates, sandboxId) + } + legacy := legacyAgentSandboxName(sandboxId) + if legacy != primary && legacy != sandboxId { + candidates = append(candidates, legacy) + } + return candidates +} + +func (a *AgentSandboxProvider) lookupAgentSandbox(sandboxId string) (*unstructured.Unstructured, error) { + candidates := resourceNameCandidates(sandboxId) + for _, name := range candidates { + indexed, err := a.informer.GetIndexer().ByIndex(sandboxNameIndex, name) + if err != nil { + return nil, fmt.Errorf("failed to query AgentSandbox index for %q: %w", name, err) + } + matches := make([]string, 0, len(indexed)) + var matchObj *unstructured.Unstructured + for _, item := range indexed { + u, ok := item.(*unstructured.Unstructured) + if !ok { + continue + } + matches = append(matches, fmt.Sprintf("%s/%s", u.GetNamespace(), u.GetName())) + if matchObj == nil { + matchObj = u + } + } + if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous sandbox id %q found in multiple namespaces: %v", sandboxId, matches) + } + if len(matches) == 1 { + return matchObj, nil + } + } + return nil, fmt.Errorf("%w: %s", ErrSandboxNotFound, sandboxId) +} + +func (a *AgentSandboxProvider) GetEndpoint(sandboxId string) (*EndpointInfo, error) { + u, err := a.lookupAgentSandbox(sandboxId) + if err != nil { + return nil, err + } + endpoint, err := a.resolveEndpointFromSandbox(sandboxId, u) + if err != nil { + return nil, err + } + accessToken := "" + ann := u.GetAnnotations() + if ann != nil { + accessToken = strings.TrimSpace(ann[AnnotationAccessToken]) + } + return &EndpointInfo{ + Endpoint: endpoint, + SecureAccessToken: accessToken, + }, nil +} + +func (a *AgentSandboxProvider) resolveEndpointFromSandbox(sandboxId string, u *unstructured.Unstructured) (string, error) { + status, ok := u.Object["status"].(map[string]any) + if !ok { + return "", fmt.Errorf("%w: sandbox %s missing status", ErrSandboxNotReady, sandboxId) + } + + // Check ready condition first; must be Ready=True to proceed. + if ready, reason, message := a.checkSandboxReadyCondition(status); !ready { + return "", fmt.Errorf("%w: sandbox %s not ready (%s: %s)", ErrSandboxNotReady, sandboxId, reason, message) + } + + serviceFQDN, _ := status["serviceFQDN"].(string) + if serviceFQDN == "" { + return "", fmt.Errorf("%w: sandbox %s has no serviceFQDN", ErrSandboxNotReady, sandboxId) + } + + return serviceFQDN, nil +} + +func (a *AgentSandboxProvider) Start(ctx context.Context) error { + a.informerFactory.Start(ctx.Done()) + + if !cache.WaitForCacheSync(ctx.Done(), a.informer.HasSynced) { + return errors.New("failed to sync AgentSandbox informer cache") + } + + return nil +} + +// checkSandboxReadyCondition inspects status.conditions for Ready=True. +// Returns (isReady, reason, message). +// +// https://github.com/kubernetes-sigs/agent-sandbox/blob/main/controllers/sandbox_controller.go#L195 +func (a *AgentSandboxProvider) checkSandboxReadyCondition(status map[string]any) (bool, string, string) { + conds, ok := status["conditions"].([]any) + if !ok { + return false, "NoConditions", "no sandbox conditions reported" + } + for _, c := range conds { + m, ok := c.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t != agentSandboxConditionReady { + continue + } + if s, _ := m["status"].(string); s == string(metav1.ConditionTrue) { + return true, agentSandboxConditionReady, "" + } + reason, _ := m["reason"].(string) + message, _ := m["message"].(string) + if reason == "" { + reason = "DependenciesNotReady" + } + if message == "" { + message = "Ready condition is not True" + } + return false, reason, message + } + + return false, "ReadyConditionMissing", "ready condition missing" +} + +var _ Provider = (*AgentSandboxProvider)(nil) diff --git a/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go b/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go new file mode 100644 index 0000000..c32918e --- /dev/null +++ b/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go @@ -0,0 +1,363 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +// buildUnstructuredSandbox creates a minimal unstructured Sandbox object. +func buildUnstructuredSandbox(name, namespace string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": agentSandboxGroup + "/" + agentSandboxVersion, + "kind": "Sandbox", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + "spec": map[string]any{ + "podTemplate": map[string]any{ + "spec": map[string]any{ + "containers": []any{}, + }, + "metadata": map[string]any{}, + }, + }, + }, + } +} + +func TestAgentSandboxProvider_Start_Success(t *testing.T) { + namespace := "test-ns" + + obj := buildUnstructuredSandbox("demo", namespace) + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + obj, + ) + + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := provider.Start(ctx) + assert.NoError(t, err, "Start should succeed with fake dynamic informer") + + // Manually seed store (fake dynamic client doesn't backfill informer cache automatically) + err = provider.informer.GetStore().Add(obj) + assert.NoError(t, err) + + key := obj.GetNamespace() + "/" + obj.GetName() + _, exists, _ := provider.informer.GetStore().GetByKey(key) + assert.True(t, exists, "informer cache should accept added object after start") +} + +func TestAgentSandboxProvider_Start_ContextCancelled(t *testing.T) { + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before start + + err := provider.Start(ctx) + assert.Error(t, err, "Start should fail when context already cancelled") +} + +func TestAgentSandboxProvider_GetEndpoint_ServiceFQDN(t *testing.T) { + namespace := "test-ns" + obj := buildUnstructuredSandbox("demo", namespace) + obj.Object["status"] = map[string]any{ + "serviceFQDN": "sandbox.demo.svc.cluster.local", + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "True", + }, + }, + } + + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := provider.Start(ctx) + assert.NoError(t, err) + + // Seed store + err = provider.informer.GetStore().Add(obj) + assert.NoError(t, err) + + endpoint, err := provider.GetEndpoint("demo") + assert.NoError(t, err) + assert.Equal(t, "sandbox.demo.svc.cluster.local", endpoint.Endpoint) +} + +func TestAgentSandboxProvider_GetEndpoint_NotFound(t *testing.T) { + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := provider.Start(ctx) + assert.NoError(t, err) + + _, err = provider.GetEndpoint("missing") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotFound)) +} + +func TestAgentSandboxProvider_GetEndpoint_NoServiceFQDN(t *testing.T) { + namespace := "test-ns" + obj := buildUnstructuredSandbox("demo", namespace) + obj.Object["status"] = map[string]any{} + + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := provider.Start(ctx) + assert.NoError(t, err) + + // Seed store + err = provider.informer.GetStore().Add(obj) + assert.NoError(t, err) + + _, err = provider.GetEndpoint("demo") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotReady)) +} + +func TestAgentSandboxProvider_GetEndpoint_NotReadyCondition(t *testing.T) { + namespace := "test-ns" + obj := buildUnstructuredSandbox("demo", namespace) + obj.Object["status"] = map[string]any{ + "serviceFQDN": "sandbox.demo.svc.cluster.local", + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "False", + "reason": "DependenciesNotReady", + "message": "Pod not ready", + }, + }, + } + + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := provider.Start(ctx) + assert.NoError(t, err) + + // Seed store + err = provider.informer.GetStore().Add(obj) + assert.NoError(t, err) + + _, err = provider.GetEndpoint("demo") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotReady)) +} + +func TestAgentSandboxProvider_GetEndpoint_GlobalWatchAcrossNamespaces(t *testing.T) { + actualNamespace := "another-ns" + obj := buildUnstructuredSandbox("demo", actualNamespace) + obj.Object["status"] = map[string]any{ + "serviceFQDN": "sandbox.demo.svc.cluster.local", + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "True", + }, + }, + } + + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + assert.NoError(t, provider.Start(ctx)) + assert.NoError(t, provider.informer.GetStore().Add(obj)) + + endpoint, err := provider.GetEndpoint("demo") + assert.NoError(t, err) + assert.Equal(t, "sandbox.demo.svc.cluster.local", endpoint.Endpoint) +} + +func TestAgentSandboxProvider_GetEndpoint_AmbiguousAcrossNamespaces(t *testing.T) { + name := "demo" + first := buildUnstructuredSandbox(name, "ns-a") + first.Object["status"] = map[string]any{ + "serviceFQDN": "sandbox.demo.ns-a.svc.cluster.local", + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "True", + }, + }, + } + second := buildUnstructuredSandbox(name, "ns-b") + second.Object["status"] = map[string]any{ + "serviceFQDN": "sandbox.demo.ns-b.svc.cluster.local", + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "True", + }, + }, + } + + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{ + Group: agentSandboxGroup, + Version: agentSandboxVersion, + Resource: agentSandboxResource, + } + fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + scheme, + map[schema.GroupVersionResource]string{ + gvr: "SandboxList", + }, + ) + provider := newAgentSandboxProviderWithClient(fakeDyn, 30*time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + assert.NoError(t, provider.Start(ctx)) + assert.NoError(t, provider.informer.GetStore().Add(first)) + assert.NoError(t, provider.informer.GetStore().Add(second)) + + _, err := provider.GetEndpoint(name) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous sandbox id") +} + +func TestToDNS1035Label_HashOnSymbolOnlyIDs(t *testing.T) { + name1 := toDNS1035Label("!!!", agentSandboxNamePrefix) + name2 := toDNS1035Label("???", agentSandboxNamePrefix) + + assert.NotEqual(t, name1, name2) + assert.Regexp(t, `^sandbox-[0-9a-f]{8}$`, name1) + assert.Regexp(t, `^sandbox-[0-9a-f]{8}$`, name2) +} + +func TestToDNS1035Label_PrefixesDigitStart(t *testing.T) { + name := toDNS1035Label("1234", agentSandboxNamePrefix) + assert.Equal(t, "sandbox-1234", name) +} + +func TestToDNS1035Label_TruncatesWithHashSuffix(t *testing.T) { + input := "A" + strings.Repeat("b", 100) + name := toDNS1035Label(input, agentSandboxNamePrefix) + + assert.LessOrEqual(t, len(name), 63) + assert.Regexp(t, `^[a-z][a-z0-9-]*$`, name) + assert.Regexp(t, `[0-9a-f]{8}$`, name) +} diff --git a/components/ingress/pkg/sandbox/batchsandbox_provider.go b/components/ingress/pkg/sandbox/batchsandbox_provider.go new file mode 100644 index 0000000..3991ecd --- /dev/null +++ b/components/ingress/pkg/sandbox/batchsandbox_provider.go @@ -0,0 +1,163 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1" + clientset "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned" + informers "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions" + listers "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/listers/sandbox/v1alpha1" + "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" +) + +type BatchSandboxProvider struct { + informerFactory informers.SharedInformerFactory + lister listers.BatchSandboxLister + informer cache.SharedIndexInformer + informerSynced cache.InformerSynced +} + +func NewBatchSandboxProvider( + config *rest.Config, + resyncPeriod time.Duration, +) *BatchSandboxProvider { + clientset, err := clientset.NewForConfig(config) + if err != nil { + panic(fmt.Sprintf("failed to create sandbox clientset: %v", err)) + } + + informerFactory := informers.NewSharedInformerFactoryWithOptions( + clientset, + resyncPeriod, + informers.WithNamespace(metav1.NamespaceAll), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + if err := batchSandboxInformer.Informer().AddIndexers(cache.Indexers{ + sandboxNameIndex: func(obj any) ([]string, error) { + bs, ok := obj.(*sandboxv1alpha1.BatchSandbox) + if !ok { + return []string{}, nil + } + return []string{bs.Name}, nil + }, + }); err != nil { + panic(fmt.Sprintf("failed to add BatchSandbox indexer: %v", err)) + } + + return &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informer: batchSandboxInformer.Informer(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } +} + +func (p *BatchSandboxProvider) Start(ctx context.Context) error { + p.informerFactory.Start(ctx.Done()) + + // Wait for cache sync + if !cache.WaitForCacheSync(ctx.Done(), p.informerSynced) { + return errors.New("failed to sync BatchSandbox informer cache") + } + + return nil +} + +func (p *BatchSandboxProvider) findBatchSandbox(sandboxId string) (*sandboxv1alpha1.BatchSandbox, error) { + matches := make([]string, 0, 1) + indexed := []any{} + needScanFallback := p.informer == nil + if p.informer != nil { + var err error + indexed, err = p.informer.GetIndexer().ByIndex(sandboxNameIndex, sandboxId) + if err != nil { + needScanFallback = true + } + } + + if needScanFallback { + all, err := p.lister.List(labels.Everything()) + if err != nil { + return nil, fmt.Errorf("failed to list BatchSandboxes: %w", err) + } + indexed = make([]any, 0, len(all)) + for _, bs := range all { + if bs.Name == sandboxId { + indexed = append(indexed, bs) + } + } + } + var selected *sandboxv1alpha1.BatchSandbox + for _, item := range indexed { + bs, ok := item.(*sandboxv1alpha1.BatchSandbox) + if !ok { + continue + } + matches = append(matches, fmt.Sprintf("%s/%s", bs.Namespace, bs.Name)) + if selected == nil { + selected = bs + } + } + if len(matches) == 0 { + return nil, fmt.Errorf("%w: %s", ErrSandboxNotFound, sandboxId) + } + if len(matches) > 1 { + return nil, fmt.Errorf("ambiguous sandbox id %q found in multiple namespaces: %v", sandboxId, matches) + } + return selected, nil +} + +// GetEndpoint retrieves the endpoint IP for a BatchSandbox +func (p *BatchSandboxProvider) GetEndpoint(sandboxId string) (*EndpointInfo, error) { + batchSandbox, err := p.findBatchSandbox(sandboxId) + if err != nil { + return nil, err + } + accessToken := "" + if batchSandbox.Annotations != nil { + accessToken = strings.TrimSpace(batchSandbox.Annotations[AnnotationAccessToken]) + } + + // Check if BatchSandbox is ready + if batchSandbox.Status.Ready < 1 { + return nil, fmt.Errorf("%w: %s/%s (ready: %d/%d)", + ErrSandboxNotReady, batchSandbox.Namespace, sandboxId, batchSandbox.Status.Ready, batchSandbox.Status.Replicas) + } + + // Get endpoints from BatchSandbox using kubernetes utils + endpoints, err := utils.GetEndpoints(batchSandbox) + if err != nil { + return nil, fmt.Errorf("%w: %s/%s: %w", ErrSandboxNotReady, batchSandbox.Namespace, sandboxId, err) + } + + // Return the first available endpoint + return &EndpointInfo{ + Endpoint: endpoints[0], + SecureAccessToken: accessToken, + }, nil +} + +var _ Provider = (*BatchSandboxProvider)(nil) diff --git a/components/ingress/pkg/sandbox/batchsandbox_provider_test.go b/components/ingress/pkg/sandbox/batchsandbox_provider_test.go new file mode 100644 index 0000000..1757375 --- /dev/null +++ b/components/ingress/pkg/sandbox/batchsandbox_provider_test.go @@ -0,0 +1,456 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1" + fakeclientset "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/fake" + informers "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions" + "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils" +) + +// Note: Integration tests with real informers are in e2e tests +// Unit tests here focus on provider behavior + +// TestBatchSandboxProvider_WithFakeInformer tests the provider using fake clientset and informer +func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { + namespace := "test-namespace" + + // Create a ready BatchSandbox with valid endpoints + readyBatchSandbox := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ready-sandbox", + Namespace: namespace, + Annotations: map[string]string{ + utils.AnnotationEndpoints: `["10.0.0.1", "10.0.0.2"]`, + }, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(2)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 2, + Ready: 2, + }, + } + + // Create a not ready BatchSandbox + notReadyBatchSandbox := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-ready-sandbox", + Namespace: namespace, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(1)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 1, + Ready: 0, + }, + } + + // Create fake clientset with test objects + fakeClient := fakeclientset.NewSimpleClientset(readyBatchSandbox, notReadyBatchSandbox) + + // Create informer factory + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(namespace), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + + // Create provider + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + // Start informer and wait for cache sync + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := provider.Start(ctx) + assert.NoError(t, err, "Provider should start successfully") + + // Manually add objects to informer cache (fake clientset doesn't auto-populate informer) + err = batchSandboxInformer.Informer().GetStore().Add(readyBatchSandbox) + assert.NoError(t, err) + err = batchSandboxInformer.Informer().GetStore().Add(notReadyBatchSandbox) + assert.NoError(t, err) + + // Test 1: Get endpoint from ready sandbox + t.Run("GetEndpoint from ready sandbox", func(t *testing.T) { + endpoint, err := provider.GetEndpoint("ready-sandbox") + assert.NoError(t, err) + assert.Equal(t, "10.0.0.1", endpoint.Endpoint, "Should return first endpoint IP") + assert.Equal(t, "", endpoint.SecureAccessToken) + }) + + // Test 2: Get endpoint from not ready sandbox + t.Run("GetEndpoint from not ready sandbox", func(t *testing.T) { + _, err := provider.GetEndpoint("not-ready-sandbox") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotReady), "Should return ErrSandboxNotReady") + assert.Contains(t, err.Error(), "not ready") + }) + + // Test 3: Get endpoint from non-existent sandbox + t.Run("GetEndpoint from non-existent sandbox", func(t *testing.T) { + _, err := provider.GetEndpoint("non-existent") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotFound), "Should return ErrSandboxNotFound") + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("GetEndpoint with non-empty access-token", func(t *testing.T) { + secureSB := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secure-sb", + Namespace: namespace, + Annotations: map[string]string{ + AnnotationAccessToken: "opaque", + utils.AnnotationEndpoints: `["10.0.0.1"]`, + }, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(1)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 1, + Ready: 1, + }, + } + err := batchSandboxInformer.Informer().GetStore().Add(secureSB) + assert.NoError(t, err) + info, err := provider.GetEndpoint("secure-sb") + assert.NoError(t, err) + assert.Equal(t, "10.0.0.1", info.Endpoint) + assert.Equal(t, "opaque", info.SecureAccessToken) + }) +} + +// TestBatchSandboxProvider_MissingAnnotation tests sandbox without endpoints annotation +func TestBatchSandboxProvider_MissingAnnotation(t *testing.T) { + namespace := "test-namespace" + + // Create BatchSandbox without endpoints annotation + batchSandbox := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "no-annotation-sandbox", + Namespace: namespace, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(1)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 1, + Ready: 1, + }, + } + + fakeClient := fakeclientset.NewSimpleClientset(batchSandbox) + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(namespace), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := provider.Start(ctx) + assert.NoError(t, err) + + // Manually add object to informer cache + err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) + assert.NoError(t, err) + + _, err = provider.GetEndpoint("no-annotation-sandbox") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotReady), "Should return ErrSandboxNotReady") + assert.Contains(t, err.Error(), "has no annotations") +} + +// TestBatchSandboxProvider_InvalidAnnotation tests sandbox with invalid annotation format +func TestBatchSandboxProvider_InvalidAnnotation(t *testing.T) { + namespace := "test-namespace" + + batchSandbox := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-annotation-sandbox", + Namespace: namespace, + Annotations: map[string]string{ + utils.AnnotationEndpoints: `invalid-json`, + }, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(1)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 1, + Ready: 1, + }, + } + + fakeClient := fakeclientset.NewSimpleClientset(batchSandbox) + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(namespace), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := provider.Start(ctx) + assert.NoError(t, err) + + // Manually add object to informer cache + err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) + assert.NoError(t, err) + + _, err = provider.GetEndpoint("invalid-annotation-sandbox") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotReady), "Should return ErrSandboxNotReady") + assert.Contains(t, err.Error(), "failed to parse") +} + +// TestBatchSandboxProvider_DynamicUpdate tests adding object after informer starts +func TestBatchSandboxProvider_DynamicUpdate(t *testing.T) { + namespace := "test-namespace" + + fakeClient := fakeclientset.NewSimpleClientset() + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(namespace), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := provider.Start(ctx) + assert.NoError(t, err) + + // Initially no sandbox exists + _, err = provider.GetEndpoint("dynamic-sandbox") + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrSandboxNotFound), "Should return ErrSandboxNotFound") + assert.Contains(t, err.Error(), "not found") + + // Add a new BatchSandbox + newBatchSandbox := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dynamic-sandbox", + Namespace: namespace, + Annotations: map[string]string{ + utils.AnnotationEndpoints: `["10.0.0.100"]`, + }, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(1)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 1, + Ready: 1, + }, + } + + _, err = fakeClient.SandboxV1alpha1().BatchSandboxes(namespace).Create( + context.Background(), newBatchSandbox, metav1.CreateOptions{}) + assert.NoError(t, err) + + // Wait for informer to pick up the change + assert.Eventually(t, func() bool { + endpoint, err := provider.GetEndpoint("dynamic-sandbox") + return err == nil && endpoint.Endpoint == "10.0.0.100" + }, 3*time.Second, 100*time.Millisecond, "Informer should eventually sync the new object") +} + +// TestBatchSandboxProvider_StartCacheSyncFailure tests cache sync timeout +func TestBatchSandboxProvider_StartCacheSyncFailure(t *testing.T) { + namespace := "test-namespace" + + fakeClient := fakeclientset.NewSimpleClientset() + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(namespace), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + // Create a context that expires immediately + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + + // Wait for context to expire + time.Sleep(10 * time.Millisecond) + + err := provider.Start(ctx) + assert.Error(t, err, "Should fail when cache sync times out") + assert.Contains(t, err.Error(), "failed to sync") +} + +// TestBatchSandboxProvider_GetEndpointNonNotFoundError tests non-IsNotFound K8s errors +func TestBatchSandboxProvider_GetEndpointNonNotFoundError(t *testing.T) { + namespace := "test-namespace" + + // Create a sandbox with Ready status but missing endpoint annotation + batchSandbox := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-endpoint-sandbox", + Namespace: namespace, + Annotations: map[string]string{ + utils.AnnotationEndpoints: `["10.0.0.1"]`, + }, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{ + Replicas: ptr(int32(1)), + }, + Status: sandboxv1alpha1.BatchSandboxStatus{ + Replicas: 1, + Ready: 1, + }, + } + + fakeClient := fakeclientset.NewSimpleClientset(batchSandbox) + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(namespace), + ) + + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := provider.Start(ctx) + assert.NoError(t, err) + + // Manually add object to informer cache + err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) + assert.NoError(t, err) + + // Should successfully get endpoint + endpoint, err := provider.GetEndpoint("missing-endpoint-sandbox") + assert.NoError(t, err) + assert.Equal(t, "10.0.0.1", endpoint.Endpoint) +} + +func TestBatchSandboxProvider_GetEndpoint_AmbiguousAcrossNamespaces(t *testing.T) { + namespaceA := "ns-a" + namespaceB := "ns-b" + sandboxName := "shared-id" + + first := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: sandboxName, + Namespace: namespaceA, + Annotations: map[string]string{ + utils.AnnotationEndpoints: `["10.0.0.1"]`, + }, + }, + Status: sandboxv1alpha1.BatchSandboxStatus{Replicas: 1, Ready: 1}, + } + second := &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: sandboxName, + Namespace: namespaceB, + Annotations: map[string]string{ + utils.AnnotationEndpoints: `["10.0.0.2"]`, + }, + }, + Status: sandboxv1alpha1.BatchSandboxStatus{Replicas: 1, Ready: 1}, + } + + fakeClient := fakeclientset.NewSimpleClientset(first, second) + informerFactory := informers.NewSharedInformerFactoryWithOptions( + fakeClient, + time.Second*30, + informers.WithNamespace(metav1.NamespaceAll), + ) + batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() + provider := &BatchSandboxProvider{ + informerFactory: informerFactory, + lister: batchSandboxInformer.Lister(), + informerSynced: batchSandboxInformer.Informer().HasSynced, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := provider.Start(ctx) + assert.NoError(t, err) + assert.NoError(t, batchSandboxInformer.Informer().GetStore().Add(first)) + assert.NoError(t, batchSandboxInformer.Informer().GetStore().Add(second)) + + _, err = provider.GetEndpoint(sandboxName) + assert.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "ambiguous sandbox id")) +} + +// ptr is a helper function to create int32 pointer +func ptr(i int32) *int32 { + return &i +} diff --git a/components/ingress/pkg/sandbox/endpoint.go b/components/ingress/pkg/sandbox/endpoint.go new file mode 100644 index 0000000..40f4e1a --- /dev/null +++ b/components/ingress/pkg/sandbox/endpoint.go @@ -0,0 +1,15 @@ +package sandbox + +// EndpointInfo is the single lookup result used by ingress routing. +type EndpointInfo struct { + // Endpoint is the resolved upstream endpoint (IP/FQDN) for this sandbox. + Endpoint string + + // SecureAccessToken is the trimmed annotation opensandbox.io/secure-access-token value. + // Empty means secure access is not required. + SecureAccessToken string +} + +func (i EndpointInfo) AccessVerificationRequired() bool { + return i.SecureAccessToken != "" +} diff --git a/components/ingress/pkg/sandbox/errors_test.go b/components/ingress/pkg/sandbox/errors_test.go new file mode 100644 index 0000000..484ae1e --- /dev/null +++ b/components/ingress/pkg/sandbox/errors_test.go @@ -0,0 +1,30 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "errors" + "fmt" + "testing" +) + +// Ensure wrapping ErrSandboxNotReady keeps errors.Is behavior. +func TestErrSandboxNotReadyWrapping(t *testing.T) { + wrapped := fmt.Errorf("%w: custom detail", ErrSandboxNotReady) + + if !errors.Is(wrapped, ErrSandboxNotReady) { + t.Fatalf("expected errors.Is to match ErrSandboxNotReady, got false; err=%v", wrapped) + } +} diff --git a/components/ingress/pkg/sandbox/factory.go b/components/ingress/pkg/sandbox/factory.go new file mode 100644 index 0000000..0ddcf4f --- /dev/null +++ b/components/ingress/pkg/sandbox/factory.go @@ -0,0 +1,46 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "fmt" + "time" + + "k8s.io/client-go/rest" +) + +type DefaultProviderFactory struct { + config *rest.Config + resyncPeriod time.Duration +} + +func NewProviderFactory(config *rest.Config, resyncPeriod time.Duration) *DefaultProviderFactory { + return &DefaultProviderFactory{ + config: config, + resyncPeriod: resyncPeriod, + } +} + +// CreateProvider creates a Provider instance based on the provider type +func (f *DefaultProviderFactory) CreateProvider(providerType ProviderType) (Provider, error) { + switch providerType { + case ProviderTypeBatchSandbox: + return NewBatchSandboxProvider(f.config, f.resyncPeriod), nil + case ProviderTypeAgentSandbox: + return NewAgentSandboxProvider(f.config, f.resyncPeriod), nil + default: + return nil, fmt.Errorf("unsupported provider type: %s", providerType) + } +} diff --git a/components/ingress/pkg/sandbox/provider.go b/components/ingress/pkg/sandbox/provider.go new file mode 100644 index 0000000..55201c0 --- /dev/null +++ b/components/ingress/pkg/sandbox/provider.go @@ -0,0 +1,64 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sandbox + +import ( + "context" + "errors" +) + +type ProviderType string + +const ( + ProviderTypeBatchSandbox ProviderType = "batchsandbox" + ProviderTypeAgentSandbox ProviderType = "agent-sandbox" + + sandboxNameIndex string = "sandbox-name" + + // AnnotationAccessToken marks a sandbox that requires signed ingress routes when non-empty. + AnnotationAccessToken = "opensandbox.io/secure-access-token" +) + +func (tpy ProviderType) String() string { return string(tpy) } + +var ( + // ErrSandboxNotFound indicates the sandbox resource does not exist + ErrSandboxNotFound = errors.New("sandbox not found") + + // ErrSandboxNotReady indicates the sandbox exists but is not ready + // This includes: not enough ready replicas, missing endpoints, invalid configuration + ErrSandboxNotReady = errors.New("sandbox not ready") +) + +// Provider defines the interface for sandbox resource providers +// Implementations include BatchSandboxProvider, AgentSandboxProvider, etc. +type Provider interface { + // GetEndpoint retrieves endpoint and secure-access metadata for a sandbox by its id/name. + // Providers run in global-watch mode across all namespaces. + // Returns the first available endpoint from provider status/annotations. + // Returns error if sandbox not found or endpoint unavailable. + // Note: This is a local cache query, no network I/O involved + GetEndpoint(sandboxId string) (*EndpointInfo, error) + + // Start initializes and starts the provider's informer cache + // Waits for cache sync before returning + // Must be called before using GetEndpoint + Start(ctx context.Context) error +} + +// ProviderFactory creates a Provider instance based on the provider type +type ProviderFactory interface { + CreateProvider(providerType ProviderType) (Provider, error) +} diff --git a/components/ingress/pkg/signature/ingress_access.go b/components/ingress/pkg/signature/ingress_access.go new file mode 100644 index 0000000..606db28 --- /dev/null +++ b/components/ingress/pkg/signature/ingress_access.go @@ -0,0 +1,123 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "crypto/subtle" + "errors" + "net/http" + "strings" +) + +const ( + // OpenSandboxSecureAccessHeader is the OSEP-0011 header field name; use + // OpenSandboxSecureAccessCanonical when looking up the HTTP header map. + OpenSandboxSecureAccessHeader = "OpenSandbox-Secure-Access" +) + +var ( + OpenSandboxSecureAccessCanonical = http.CanonicalHeaderKey(OpenSandboxSecureAccessHeader) + + ErrSecureHeaderMismatch = errors.New("signature: secure access header mismatch") + ErrSignatureRequired = errors.New("signature: signature required for this sandbox") + ErrVerifierNotConfigured = errors.New("signature: ingress verifier not configured") +) + +// SecureAccessHeaderInfo reports field presence: present iff the header field +// is sent (values may be empty) and the trimmed first value for comparison. +func SecureAccessHeaderInfo(r *http.Request) (present bool, value string) { + if r == nil { + return false, "" + } + vs := r.Header.Values(OpenSandboxSecureAccessCanonical) + if len(vs) == 0 { + return false, "" + } + return true, strings.TrimSpace(vs[0]) +} + +// SecureAccessHeaderFromRequest returns the trimmed first field value, or "" if absent. +func SecureAccessHeaderFromRequest(r *http.Request) string { + _, v := SecureAccessHeaderInfo(r) + return v +} + +func secureAccessTokenEqualConstantTime(a, b string) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +type IngressAccessInput struct { + Secure bool + ExpectedAccessToken string + SecureAccessHeaderPresent bool + RequestedAccessToken string + ExpiresB36 string + Signature string + SandboxID string + Port int + Verifier *Verifier +} + +// CheckIngressSecureAccess applies OSEP-0011: if OpenSandbox-Secure-Access is +// present, compare to annotation token (constant-time) and 401 on mismatch +// (no route-signature fallback). If absent, verify route signature+expiry. +func CheckIngressSecureAccess(in IngressAccessInput) error { + if !in.Secure { + return nil + } + + at := strings.TrimSpace(in.ExpectedAccessToken) + if in.SecureAccessHeaderPresent { + if secureAccessTokenEqualConstantTime(in.RequestedAccessToken, at) { + return nil + } + return ErrSecureHeaderMismatch + } + if in.Signature != "" && strings.TrimSpace(in.ExpiresB36) != "" { + if in.Verifier == nil || !in.Verifier.Enabled() { + return ErrVerifierNotConfigured + } + return in.Verifier.VerifySignature(in.Signature, in.SandboxID, in.Port, in.ExpiresB36) + } + return ErrSignatureRequired +} + +func HTTPStatusForIngressErr(err error) int { + if err == nil { + return 0 + } + if errors.Is(err, ErrUnauthorized) { + return http.StatusUnauthorized + } + if errors.Is(err, ErrAccessExpired) { + return http.StatusUnauthorized + } + if errors.Is(err, ErrSecureHeaderMismatch) { + return http.StatusUnauthorized + } + if errors.Is(err, ErrSignatureRequired) { + return http.StatusUnauthorized + } + if errors.Is(err, ErrVerifierNotConfigured) { + return http.StatusServiceUnavailable + } + return http.StatusBadRequest +} diff --git a/components/ingress/pkg/signature/signature.go b/components/ingress/pkg/signature/signature.go new file mode 100644 index 0000000..febdeb4 --- /dev/null +++ b/components/ingress/pkg/signature/signature.go @@ -0,0 +1,237 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +var ( + ErrUnauthorized = errors.New("signature: unauthorized") + ErrAccessExpired = errors.New("signature: access expired") +) + +type Verifier struct { + Keys map[string][]byte +} + +func (v *Verifier) Enabled() bool { + return v != nil && len(v.Keys) > 0 +} + +// ParseKeys parses ingress --secure-access-keys: "a=BASE64,b=BASE64" (comma-separated, key_id exactly 1 char [0-9a-z]). +func ParseKeys(s string) (map[string][]byte, error) { + if strings.TrimSpace(s) == "" { + return nil, errors.New("empty keys string") + } + out := make(map[string][]byte) + for _, seg := range strings.Split(s, ",") { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + key, val, ok := strings.Cut(seg, "=") + if !ok || key == "" || val == "" { + return nil, fmt.Errorf("invalid keys segment %q (want key_id=base64)", seg) + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if len(key) != 1 { + return nil, fmt.Errorf("key_id must be exactly 1 character, got %q", key) + } + r := key[0] + if r >= 'A' && r <= 'Z' { + return nil, fmt.Errorf("key_id must be lowercase [0-9a-z], got %q", key) + } + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) { + return nil, fmt.Errorf("key_id must be [0-9a-z], got %q", key) + } + raw, err := base64.StdEncoding.DecodeString(val) + if err != nil { + return nil, fmt.Errorf("decode secret for key %q: %w", key, err) + } + if len(raw) == 0 { + return nil, fmt.Errorf("empty secret for key %q", key) + } + out[key] = raw + } + if len(out) == 0 { + return nil, errors.New("no keys parsed") + } + return out, nil +} + +const maxExpiresB36Len = 13 + +// ParseExpiresB36 parses OSEP expires_b36 (FormatUint(sec, 36), no leading zeros; "0" for zero). +func ParseExpiresB36(s string) (uint64, error) { + if s == "" { + return 0, fmt.Errorf("empty expires_b36") + } + if len(s) > maxExpiresB36Len { + return 0, fmt.Errorf("expires_b36 too long") + } + for _, c := range s { + if c >= 'A' && c <= 'Z' { + return 0, fmt.Errorf("expires_b36 must be lowercase [0-9a-z]") + } + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) { + return 0, fmt.Errorf("invalid character in expires_b36") + } + } + if len(s) > 1 && s[0] == '0' { + return 0, fmt.Errorf("expires_b36 must not have leading zeros") + } + return strconv.ParseUint(s, 36, 64) +} + +// ValidateSignatureFormat checks OSEP-0011 route signature: 8 hex + 1 key_id [0-9a-z] (9 chars). +func ValidateSignatureFormat(signature string) error { + if len(signature) != 9 { + return fmt.Errorf("signature must be 9 characters, got %d", len(signature)) + } + for i := 0; i < 8; i++ { + c := signature[i] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return fmt.Errorf("signature hex8 must be lowercase hex") + } + } + c := signature[8] + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')) { + return fmt.Errorf("signed_key_id must be [0-9a-z]") + } + return nil +} + +func ParsePortSegment(portStr string) (int, error) { + if len(portStr) > 1 && portStr[0] == '0' { + return 0, fmt.Errorf("port must not have leading zeros") + } + p, err := strconv.Atoi(portStr) + if err != nil || p < 1 || p > 65535 { + return 0, fmt.Errorf("invalid port %q", portStr) + } + return p, nil +} + +// ParseRouteToken parses a host label: unsigned "-" or signed right-split +// "---". +func ParseRouteToken(s string) (sandboxID string, port int, expiresB36, signature string, err error) { + parts := strings.Split(s, "-") + switch len(parts) { + case 0, 1: + return "", 0, "", "", fmt.Errorf("expected - or signed host label, got %d segments", len(parts)) + case 2: + sandboxID = parts[0] + if sandboxID == "" { + return "", 0, "", "", fmt.Errorf("empty sandbox_id") + } + p, perr := ParsePortSegment(parts[1]) + if perr != nil { + return "", 0, "", "", perr + } + return sandboxID, p, "", "", nil + default: + if len(parts) < 4 { + return "", 0, "", "", fmt.Errorf("signed host label needs at least 4 segments, got %d", len(parts)) + } + signature = parts[len(parts)-1] + if err := ValidateSignatureFormat(signature); err != nil { + return "", 0, "", "", err + } + expiresB36 = parts[len(parts)-2] + if _, err := ParseExpiresB36(expiresB36); err != nil { + return "", 0, "", "", err + } + portStr := parts[len(parts)-3] + p, err := ParsePortSegment(portStr) + if err != nil { + return "", 0, "", "", err + } + sandboxID = strings.Join(parts[:len(parts)-3], "-") + if sandboxID == "" { + return "", 0, "", "", fmt.Errorf("empty sandbox_id") + } + return sandboxID, p, expiresB36, signature, nil + } +} + +// CanonicalBytes is the UTF-8 canonical string (v1\nshort\n{sandbox_id}\n{port}\n{expires_b36}\n). +func CanonicalBytes(sandboxID string, port int, expiresB36 string) []byte { + return []byte(fmt.Sprintf("v1\nshort\n%s\n%d\n%s\n", sandboxID, port, expiresB36)) +} + +func Inner(secret, canonical []byte) []byte { + var buf []byte + buf = binary.BigEndian.AppendUint32(buf, uint32(len(secret))) + buf = append(buf, secret...) + buf = binary.BigEndian.AppendUint32(buf, uint32(len(canonical))) + buf = append(buf, canonical...) + return buf +} + +func ExpectedHex8(inner []byte) string { + sum := sha256.Sum256(inner) + const hex = "0123456789abcdef" + out := make([]byte, 8) + for i := 0; i < 4; i++ { + b := sum[i] + out[i*2] = hex[b>>4] + out[i*2+1] = hex[b&0x0f] + } + return string(out) +} + +// VerifySignature checks route signature and expiry (now must be before or at expires). +func (v *Verifier) VerifySignature(signature, sandboxID string, port int, expiresB36 string) error { + if !v.Enabled() { + return nil + } + if err := ValidateSignatureFormat(signature); err != nil { + return err + } + expiresSec, err := ParseExpiresB36(expiresB36) + if err != nil { + return err + } + nowSec := time.Now().Unix() + if nowSec < 0 || uint64(nowSec) > expiresSec { + return ErrAccessExpired + } + hex8 := signature[:8] + signedKeyID := signature[8:9] + secret, ok := v.Keys[signedKeyID] + if !ok { + return fmt.Errorf("%w: unknown signed_key_id", ErrUnauthorized) + } + canonical := CanonicalBytes(sandboxID, port, expiresB36) + inner := Inner(secret, canonical) + want := ExpectedHex8(inner) + if hex8 != want { + return fmt.Errorf("%w: signature mismatch", ErrUnauthorized) + } + return nil +} + +func HTTPStatusForErr(err error) int { + return HTTPStatusForIngressErr(err) +} diff --git a/components/ingress/pkg/signature/signature_test.go b/components/ingress/pkg/signature/signature_test.go new file mode 100644 index 0000000..69f4eae --- /dev/null +++ b/components/ingress/pkg/signature/signature_test.go @@ -0,0 +1,192 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package signature + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func testExpiresB36(t *testing.T) string { + t.Helper() + return strconv.FormatUint(uint64(time.Now().Add(1*time.Hour).Unix()), 36) +} + +func TestParseRouteToken_RightSplit(t *testing.T) { + e := "1a2b3c" + id, port, ex, sig, err := ParseRouteToken("alpha-beta-8080-" + e + "-abcdef12k") + assert.NoError(t, err) + assert.Equal(t, "alpha-beta", id) + assert.Equal(t, 8080, port) + assert.Equal(t, e, ex) + assert.Equal(t, "abcdef12k", sig) + + id, port, ex, sig, err = ParseRouteToken("sandbox-8080") + assert.NoError(t, err) + assert.Equal(t, "sandbox", id) + assert.Equal(t, 8080, port) + assert.Equal(t, "", ex) + assert.Equal(t, "", sig) + + _, _, _, _, err = ParseRouteToken("only-two") + assert.Error(t, err) +} + +func TestParseRouteToken_LeadingZeroPort(t *testing.T) { + _, _, _, _, err := ParseRouteToken("sb-08080-0-abcdef12k") + assert.Error(t, err) +} + +func TestInnerAndExpectedHex8(t *testing.T) { + secret := []byte{0x01, 0x02, 0x03} + exp := "0" + canonical := CanonicalBytes("sb", 42, exp) + inner := Inner(secret, canonical) + assert.Len(t, inner, 4+len(secret)+4+len(canonical)) + h := ExpectedHex8(inner) + assert.Len(t, h, 8) + for _, c := range h { + assert.True(t, (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'), "got %q", h) + } +} + +func TestVerifySignature_OKAnd401(t *testing.T) { + secret := []byte("test-secret-bytes") + sb := "my-sandbox" + port := 9000 + exp := testExpiresB36(t) + hex8 := ExpectedHex8(Inner(secret, CanonicalBytes(sb, port, exp))) + sig := hex8 + "z" + + v := &Verifier{Keys: map[string][]byte{"z": secret}} + assert.NoError(t, v.VerifySignature(sig, sb, port, exp)) + + badSig := "00000000" + "z" + err := v.VerifySignature(badSig, sb, port, exp) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrUnauthorized)) + + bad10 := hex8 + "zz" + err = v.VerifySignature(bad10, sb, port, exp) + assert.Error(t, err) +} + +func TestVerifySignature_ExpiryComparisonAvoidsUint64Overflow(t *testing.T) { + secret := []byte("test-secret-bytes") + sb := "my-sandbox" + port := 9000 + exp := strconv.FormatUint(^uint64(0), 36) + hex8 := ExpectedHex8(Inner(secret, CanonicalBytes(sb, port, exp))) + sig := hex8 + "z" + + v := &Verifier{Keys: map[string][]byte{"z": secret}} + assert.NoError(t, v.VerifySignature(sig, sb, port, exp)) +} + +func TestHTTPStatusForErr(t *testing.T) { + assert.Equal(t, http.StatusUnauthorized, HTTPStatusForErr(fmt.Errorf("%w: x", ErrUnauthorized))) + assert.Equal(t, http.StatusUnauthorized, HTTPStatusForErr(ErrAccessExpired)) + assert.Equal(t, http.StatusBadRequest, HTTPStatusForErr(fmt.Errorf("bad format"))) + assert.Equal(t, 0, HTTPStatusForErr(nil)) + assert.Equal(t, http.StatusUnauthorized, HTTPStatusForIngressErr(ErrSecureHeaderMismatch)) + assert.Equal(t, http.StatusUnauthorized, HTTPStatusForIngressErr(ErrSignatureRequired)) + assert.Equal(t, http.StatusServiceUnavailable, HTTPStatusForIngressErr(ErrVerifierNotConfigured)) +} + +func TestCheckIngressSecureAccess(t *testing.T) { + secret := []byte("k") + v := &Verifier{Keys: map[string][]byte{"z": secret}} + sb, port := "s", 1 + e := testExpiresB36(t) + sig := ExpectedHex8(Inner(secret, CanonicalBytes(sb, port, e))) + "z" + + assert.NoError(t, CheckIngressSecureAccess(IngressAccessInput{ + Secure: false, + Signature: sig, + SandboxID: sb, + Port: port, + Verifier: v, + })) + + assert.NoError(t, CheckIngressSecureAccess(IngressAccessInput{ + Secure: true, + ExpectedAccessToken: "tok", + SecureAccessHeaderPresent: true, + RequestedAccessToken: "tok", + Signature: "bad", + SandboxID: sb, + Port: port, + })) + + assert.ErrorIs(t, CheckIngressSecureAccess(IngressAccessInput{ + Secure: true, + ExpectedAccessToken: "tok", + SecureAccessHeaderPresent: true, + RequestedAccessToken: "nope", + Signature: sig, + SandboxID: sb, + Port: port, + Verifier: v, + }), ErrSecureHeaderMismatch) + + assert.ErrorIs(t, CheckIngressSecureAccess(IngressAccessInput{ + Secure: true, + ExpectedAccessToken: "tok", + Signature: sig, + ExpiresB36: e, + SandboxID: sb, + Port: port, + Verifier: nil, + }), ErrVerifierNotConfigured) + + assert.NoError(t, CheckIngressSecureAccess(IngressAccessInput{ + Secure: true, + ExpectedAccessToken: "tok", + Signature: sig, + ExpiresB36: e, + SandboxID: sb, + Port: port, + Verifier: v, + })) + + assert.ErrorIs(t, CheckIngressSecureAccess(IngressAccessInput{ + Secure: true, + ExpectedAccessToken: "tok", + Signature: "", + ExpiresB36: "", + SandboxID: sb, + Port: port, + }), ErrSignatureRequired) +} + +func TestParseKeys(t *testing.T) { + raw := []byte{0xab, 0xcd} + keys, err := ParseKeys("k=" + base64.StdEncoding.EncodeToString(raw)) + assert.NoError(t, err) + assert.Equal(t, raw, keys["k"]) + + _, err = ParseKeys("") + assert.Error(t, err) + + _, err = ParseKeys("K=" + base64.StdEncoding.EncodeToString(raw)) + assert.Error(t, err) +} diff --git a/components/ingress/pkg/telemetry/hostmetrics_linux.go b/components/ingress/pkg/telemetry/hostmetrics_linux.go new file mode 100644 index 0000000..8e9c0ca --- /dev/null +++ b/components/ingress/pkg/telemetry/hostmetrics_linux.go @@ -0,0 +1,53 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package telemetry + +import ( + "github.com/shirou/gopsutil/cpu" + "github.com/shirou/gopsutil/mem" + "github.com/shirou/gopsutil/net" +) + +func systemMemoryUsedBytes() int64 { + stats, err := mem.VirtualMemory() + if err != nil { + return 0 + } + return int64(stats.Used) +} + +func cpuUtilizationRatio() float64 { + usage, err := cpu.Percent(0, false) + if err != nil || len(usage) == 0 { + return 0 + } + return usage[0] / 100.0 +} + +func activeNetworkConnections() int64 { + conns, err := net.Connections("tcp") + if err != nil { + return 0 + } + var count int64 + for _, c := range conns { + if c.Status == "ESTABLISHED" { + count++ + } + } + return count +} diff --git a/components/ingress/pkg/telemetry/hostmetrics_stub.go b/components/ingress/pkg/telemetry/hostmetrics_stub.go new file mode 100644 index 0000000..580f0eb --- /dev/null +++ b/components/ingress/pkg/telemetry/hostmetrics_stub.go @@ -0,0 +1,23 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !linux + +package telemetry + +func systemMemoryUsedBytes() int64 { return 0 } + +func cpuUtilizationRatio() float64 { return 0 } + +func activeNetworkConnections() int64 { return 0 } diff --git a/components/ingress/pkg/telemetry/init.go b/components/ingress/pkg/telemetry/init.go new file mode 100644 index 0000000..ccf18ea --- /dev/null +++ b/components/ingress/pkg/telemetry/init.go @@ -0,0 +1,31 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + + inttelemetry "github.com/alibaba/opensandbox/internal/telemetry" + "github.com/alibaba/opensandbox/internal/version" +) + +const serviceName = "opensandbox-ingress" + +func Init(ctx context.Context) (shutdown func(context.Context) error, err error) { + return inttelemetry.Init(ctx, inttelemetry.Config{ + ServiceName: serviceName + "-" + version.Version, + RegisterMetrics: registerIngressMetrics, + }) +} diff --git a/components/ingress/pkg/telemetry/metrics.go b/components/ingress/pkg/telemetry/metrics.go new file mode 100644 index 0000000..d5c0bd4 --- /dev/null +++ b/components/ingress/pkg/telemetry/metrics.go @@ -0,0 +1,130 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +var ( + meter metric.Meter + + httpRequestCount metric.Int64Counter + httpRequestDuration metric.Float64Histogram + + routingResolutions metric.Int64Counter + routingResolutionDuration metric.Float64Histogram +) + +func registerIngressMetrics() error { + meter = otel.Meter("opensandbox/ingress") + + var err error + httpRequestCount, err = meter.Int64Counter( + "ingress.http.request.count", + metric.WithDescription("Ingress HTTP request count"), + ) + if err != nil { + return err + } + + httpRequestDuration, err = meter.Float64Histogram( + "ingress.http.request.duration", + metric.WithDescription("Ingress HTTP request duration"), + metric.WithUnit("ms"), + ) + if err != nil { + return err + } + + routingResolutions, err = meter.Int64Counter( + "ingress.routing.resolutions.count", + metric.WithDescription("Routing resolution count by result"), + ) + if err != nil { + return err + } + + routingResolutionDuration, err = meter.Float64Histogram( + "ingress.routing.resolution.duration", + metric.WithDescription("Routing resolution duration"), + metric.WithUnit("ms"), + ) + if err != nil { + return err + } + + _, err = meter.Float64ObservableGauge( + "ingress.system.cpu.usage", + metric.WithDescription("System CPU utilization ratio 0-1"), + metric.WithUnit("1"), + metric.WithFloat64Callback(func(_ context.Context, obs metric.Float64Observer) error { + obs.Observe(cpuUtilizationRatio()) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "ingress.system.memory.usage_bytes", + metric.WithDescription("System memory used bytes"), + metric.WithUnit("By"), + metric.WithInt64Callback(func(_ context.Context, obs metric.Int64Observer) error { + obs.Observe(systemMemoryUsedBytes()) + return nil + }), + ) + if err != nil { + return err + } + + _, err = meter.Int64ObservableGauge( + "ingress.connections.active", + metric.WithDescription("Current active network connections (TCP ESTABLISHED)"), + metric.WithInt64Callback(func(_ context.Context, obs metric.Int64Observer) error { + obs.Observe(activeNetworkConnections()) + return nil + }), + ) + return err +} + +func RecordHTTPRequest(method string, statusCode int, proxyType string, durationMs float64) { + if httpRequestCount == nil { + return + } + attrs := metric.WithAttributes( + attribute.String("http_method", method), + attribute.Int("http_status_code", statusCode), + attribute.String("proxy_type", proxyType), + ) + httpRequestCount.Add(context.Background(), 1, attrs) + httpRequestDuration.Record(context.Background(), durationMs, attrs) +} + +func RecordRouting(result string, durationMs float64) { + if routingResolutions == nil { + return + } + attrs := metric.WithAttributes(attribute.String("routing_result", result)) + routingResolutions.Add(context.Background(), 1, attrs) + routingResolutionDuration.Record(context.Background(), durationMs, attrs) +} diff --git a/components/internal/cmd/supervisor/main.go b/components/internal/cmd/supervisor/main.go new file mode 100644 index 0000000..495625a --- /dev/null +++ b/components/internal/cmd/supervisor/main.go @@ -0,0 +1,194 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command opensandbox-supervisor wraps a single worker process with restart +// backoff, lifecycle hooks, and a structured event log. It is designed to +// run as a container ENTRYPOINT or as a child of another process; it does +// not assume PID 1 and performs no zombie reaping. +// +// Usage: +// +// opensandbox-supervisor [flags] -- [worker-args...] +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/alibaba/opensandbox/internal/logger" + "github.com/alibaba/opensandbox/internal/supervisor" + "github.com/alibaba/opensandbox/internal/version" + "gopkg.in/natefinch/lumberjack.v2" +) + +// multiFlag collects a repeatable flag into a string slice. +type multiFlag []string + +func (m *multiFlag) String() string { return fmt.Sprintf("%v", *m) } +func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } + +func main() { + version.EchoVersion("OpenSandbox Supervisor") + + var ( + preStart multiFlag + postExit multiFlag + eventLog string + backoffMin time.Duration + backoffMax time.Duration + backoffJitter float64 + stableAfter time.Duration + burstWindow time.Duration + burstMax int + onBurst bool + grace time.Duration + preTimeout time.Duration + postTimeout time.Duration + name string + logLevel string + ) + + fs := flag.NewFlagSet("opensandbox-supervisor", flag.ExitOnError) + fs.Var(&preStart, "pre-start", "Executable to run before each worker launch (repeatable). No shell expansion; wrap in a script if needed.") + fs.Var(&postExit, "post-exit", "Executable to run after each worker exit (repeatable). Receives WORKER_* env. Failures are logged, not fatal.") + fs.StringVar(&eventLog, "event-log", "", "Path to JSONL event log. Empty = stderr.") + fs.DurationVar(&backoffMin, "backoff-min", time.Second, "Minimum restart backoff.") + fs.DurationVar(&backoffMax, "backoff-max", 30*time.Second, "Maximum restart backoff (exponential capped here).") + fs.Float64Var(&backoffJitter, "backoff-jitter", 0.1, "Backoff jitter fraction (0 disables, e.g. 0.1 = ±10%). Negative clamped to 0.") + fs.DurationVar(&stableAfter, "stable-after", 60*time.Second, "Worker uptime after which backoff resets.") + fs.DurationVar(&burstWindow, "burst-window", 5*time.Minute, "Crashloop budget sliding window.") + fs.IntVar(&burstMax, "burst-max", 10, "Max launches inside burst-window before tripping the breaker.") + fs.BoolVar(&onBurst, "on-burst-exit", true, "true: supervisor exits non-zero when the burst budget trips, so a higher-level supervisor (e.g. kubelet) reacts. false: keep retrying.") + fs.DurationVar(&grace, "grace-period", 10*time.Second, "Time between SIGTERM and SIGKILL when shutting the worker down.") + fs.DurationVar(&preTimeout, "pre-start-timeout", 30*time.Second, "Timeout for each pre-start hook.") + fs.DurationVar(&postTimeout, "post-exit-timeout", 30*time.Second, "Timeout for each post-exit hook.") + fs.StringVar(&name, "name", "", "Worker name shown in logs and events (default: basename of the worker cmd).") + fs.StringVar(&logLevel, "log-level", "info", "Supervisor diagnostic log level (debug|info|warn|error).") + + args := os.Args[1:] + workerArgs := splitOnDoubleDash(&args) + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + if len(workerArgs) == 0 { + fmt.Fprintln(os.Stderr, "opensandbox-supervisor: missing worker command after `--`") + fs.Usage() + os.Exit(2) + } + + log := logger.MustNew(logger.Config{Level: logLevel}).Named("supervisor") + defer log.Sync() + + eventWriter, closer, err := openEventLog(eventLog) + if err != nil { + log.Errorf("event log: %v", err) + os.Exit(2) + } + defer closer() + + spec := supervisor.Spec{ + Name: name, + Cmd: workerArgs[0], + Args: workerArgs[1:], + PreStart: toHooks(preStart), + PostExit: toHooks(postExit), + BackoffMin: backoffMin, + BackoffMax: backoffMax, + BackoffJitter: &backoffJitter, + StableAfter: stableAfter, + BurstWindow: burstWindow, + BurstMax: burstMax, + OnBurstExit: &onBurst, + GracePeriod: grace, + PreStartTimeout: preTimeout, + PostExitTimeout: postTimeout, + EventLog: eventWriter, + Logger: log, + } + if spec.Name == "" { + spec.Name = filepath.Base(spec.Cmd) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + log.Infof("supervising %q (event-log=%s)", spec.Cmd, eventLogDest(eventLog)) + err = supervisor.Run(ctx, spec) + switch { + case err == nil, errors.Is(err, context.Canceled): + os.Exit(0) + case errors.Is(err, supervisor.ErrBurstExceeded): + log.Errorf("supervisor: %v", err) + os.Exit(1) + default: + log.Errorf("supervisor: %v", err) + os.Exit(2) + } +} + +// splitOnDoubleDash takes everything after the first "--" as the worker +// argv and trims the supervisor flag slice in place. +func splitOnDoubleDash(args *[]string) []string { + for i, a := range *args { + if a == "--" { + worker := append([]string(nil), (*args)[i+1:]...) + *args = (*args)[:i] + return worker + } + } + return nil +} + +func toHooks(paths []string) []supervisor.Hook { + if len(paths) == 0 { + return nil + } + out := make([]supervisor.Hook, 0, len(paths)) + for _, p := range paths { + out = append(out, supervisor.Hook{Argv: []string{p}}) + } + return out +} + +func openEventLog(path string) (io.Writer, func(), error) { + if path == "" { + return os.Stderr, func() {}, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + lj := &lumberjack.Logger{ + Filename: path, + MaxSize: logger.DefaultRotateMaxSize, + MaxAge: logger.DefaultRotateMaxAge, + MaxBackups: logger.DefaultRotateMaxBackups, + Compress: true, + } + return lj, func() { _ = lj.Close() }, nil +} + +func eventLogDest(path string) string { + if path == "" { + return "stderr" + } + return path +} diff --git a/components/internal/cmd/supervisor/main_test.go b/components/internal/cmd/supervisor/main_test.go new file mode 100644 index 0000000..d141a0c --- /dev/null +++ b/components/internal/cmd/supervisor/main_test.go @@ -0,0 +1,130 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestSplitOnDoubleDash(t *testing.T) { + cases := []struct { + name string + in []string + wantSupArgs []string + wantWorkerArgs []string + }{ + { + name: "typical: flags then worker", + in: []string{"--flag=a", "--", "/bin/egress", "-foo"}, + wantSupArgs: []string{"--flag=a"}, + wantWorkerArgs: []string{"/bin/egress", "-foo"}, + }, + { + name: "no double-dash returns nil worker, args untouched", + in: []string{"--flag=a", "/bin/egress"}, + wantSupArgs: []string{"--flag=a", "/bin/egress"}, + wantWorkerArgs: nil, + }, + { + name: "trailing double-dash, no worker", + in: []string{"--flag=a", "--"}, + wantSupArgs: []string{"--flag=a"}, + // append(nil, emptySlice...) returns nil, not []string{}. + wantWorkerArgs: nil, + }, + { + name: "second '--' belongs to worker argv", + in: []string{"--flag=a", "--", "/bin/sh", "-c", "foo -- bar"}, + wantSupArgs: []string{"--flag=a"}, + wantWorkerArgs: []string{"/bin/sh", "-c", "foo -- bar"}, + }, + { + name: "double-dash first means no supervisor flags", + in: []string{"--", "/bin/egress"}, + wantSupArgs: []string{}, + wantWorkerArgs: []string{"/bin/egress"}, + }, + { + name: "empty input", + in: []string{}, + wantSupArgs: nil, // append(nil, []string{}...) returns nil + wantWorkerArgs: nil, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + args := append([]string(nil), c.in...) + gotWorker := splitOnDoubleDash(&args) + if !reflect.DeepEqual(args, c.wantSupArgs) { + t.Errorf("supervisor args = %v, want %v", args, c.wantSupArgs) + } + if !reflect.DeepEqual(gotWorker, c.wantWorkerArgs) { + t.Errorf("worker args = %v, want %v", gotWorker, c.wantWorkerArgs) + } + }) + } +} + +func TestToHooks(t *testing.T) { + if got := toHooks(nil); got != nil { + t.Errorf("nil input: got %v, want nil", got) + } + if got := toHooks([]string{}); got != nil { + t.Errorf("empty input: got %v, want nil", got) + } + got := toHooks([]string{"/a/b.sh", "/c/d.sh"}) + if len(got) != 2 || got[0].Argv[0] != "/a/b.sh" || got[1].Argv[0] != "/c/d.sh" { + t.Errorf("unexpected hooks: %+v", got) + } +} + +func TestOpenEventLog_StderrWhenEmpty(t *testing.T) { + w, closer, err := openEventLog("") + if err != nil { + t.Fatal(err) + } + defer closer() + if w != os.Stderr { + t.Errorf("empty path should return os.Stderr, got %T", w) + } +} + +func TestOpenEventLog_CreatesParentDir(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "nested", "deeper", "events.jsonl") + w, closer, err := openEventLog(target) + if err != nil { + t.Fatal(err) + } + defer closer() + if _, statErr := os.Stat(filepath.Dir(target)); statErr != nil { + t.Errorf("parent dir not created: %v", statErr) + } + if w == nil { + t.Error("writer is nil") + } +} + +func TestEventLogDest(t *testing.T) { + if eventLogDest("") != "stderr" { + t.Error("empty path label") + } + if eventLogDest("/var/log/x.jsonl") != "/var/log/x.jsonl" { + t.Error("path label") + } +} diff --git a/components/internal/go.mod b/components/internal/go.mod new file mode 100644 index 0000000..44eee1d --- /dev/null +++ b/components/internal/go.mod @@ -0,0 +1,35 @@ +module github.com/alibaba/opensandbox/internal + +go 1.25.0 + +require ( + go.opentelemetry.io/otel v1.43.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 + go.opentelemetry.io/otel/metric v1.43.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/sdk/metric v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 + go.uber.org/zap v1.27.1 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + k8s.io/apimachinery v0.34.2 +) + +require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + k8s.io/klog/v2 v2.130.1 // indirect +) diff --git a/components/internal/go.sum b/components/internal/go.sum new file mode 100644 index 0000000..df94eac --- /dev/null +++ b/components/internal/go.sum @@ -0,0 +1,69 @@ +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= diff --git a/components/internal/logger/logger.go b/components/internal/logger/logger.go new file mode 100644 index 0000000..f78a095 --- /dev/null +++ b/components/internal/logger/logger.go @@ -0,0 +1,36 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger + +// Field is a structured logging key/value pair. +type Field struct { + Key string + Value any +} + +// Logger defines the minimal logging surface shared by components. +// - Formatted levels: Debugf/Infof/Warnf/Errorf +// - With: attach structured fields to derived logger +// - Named: derive a sub-logger with name +// - Sync: flush buffers (no-op for implementations that don't buffer) +type Logger interface { + Debugf(template string, args ...any) + Infof(template string, args ...any) + Warnf(template string, args ...any) + Errorf(template string, args ...any) + With(fields ...Field) Logger + Named(name string) Logger + Sync() error +} diff --git a/components/internal/logger/zap.go b/components/internal/logger/zap.go new file mode 100644 index 0000000..93f280d --- /dev/null +++ b/components/internal/logger/zap.go @@ -0,0 +1,292 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logger + +import ( + "fmt" + "os" + "strings" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +const envLogOutput = "OPENSANDBOX_LOG_OUTPUT" + +const ( + // DefaultRotateMaxSize is the default max size in megabytes before rotation. + DefaultRotateMaxSize = 100 + // DefaultRotateMaxAge is the default max days to retain old log files. + DefaultRotateMaxAge = 30 + // DefaultRotateMaxBackups is the default max old files to retain. + DefaultRotateMaxBackups = 10 +) + +// RotateConfig controls log file rotation for file-based output paths. +type RotateConfig struct { + // MaxSize is the maximum size in megabytes before rotation (default 100). + MaxSize int + // MaxAge is the maximum number of days to retain old log files (default 30). + MaxAge int + // MaxBackups is the maximum number of old log files to retain (default 10). + MaxBackups int + // Compress determines whether rotated files are gzip-compressed (default true). + Compress bool +} + +func (r *RotateConfig) applyDefaults() { + if r.MaxSize <= 0 { + r.MaxSize = DefaultRotateMaxSize + } + if r.MaxAge <= 0 { + r.MaxAge = DefaultRotateMaxAge + } + if r.MaxBackups <= 0 { + r.MaxBackups = DefaultRotateMaxBackups + } +} + +// Config is the minimal configuration to align execd/ingress defaults. +// - JSON encoding, ISO8601 time +// - Caller/stacktrace disabled +// - Stdout as default output +// - Level defaults to info +type Config struct { + Level string // debug|info|warn|error|fatal (default: info) + OutputPaths []string // default: stdout + ErrorOutputPaths []string // default: OutputPaths + Rotate *RotateConfig // nil means no rotation on file outputs +} + +// New creates a zap-backed Logger with the provided config. +// Log file rotation is enabled by default for file-based output paths. +func New(cfg Config) (Logger, error) { + cfg = applyEnvOutputs(cfg) + if cfg.Rotate == nil { + cfg.Rotate = &RotateConfig{} + } + return newWithRotate(cfg) +} + +// MustNew is a convenience helper that panics on error. +func MustNew(cfg Config) Logger { + l, err := New(cfg) + if err != nil { + panic(err) + } + return l +} + +// NewWithExtraCores tees extra zap cores after the production JSON core (e.g. OTLP). +func NewWithExtraCores(cfg Config, extra ...zapcore.Core) (Logger, error) { + if len(extra) == 0 { + return New(cfg) + } + cfg = applyEnvOutputs(cfg) + if cfg.Rotate == nil { + cfg.Rotate = &RotateConfig{} + } + return newWithRotate(cfg, extra...) +} + +// newWithRotate builds a Logger with lumberjack-backed file writers. +func newWithRotate(cfg Config, extra ...zapcore.Core) (Logger, error) { + cfg.Rotate.applyDefaults() + + // Fail fast on bad paths/permissions, matching the original zap.Config.Build + // behaviour that opens sinks at init time. + for _, paths := range [][]string{cfg.OutputPaths, cfg.ErrorOutputPaths} { + for _, path := range paths { + if err := validateOutputPath(path); err != nil { + return nil, fmt.Errorf("invalid output path %q: %w", path, err) + } + } + } + + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + encoderCfg.CallerKey = "" + encoderCfg.StacktraceKey = "" + encoder := zapcore.NewJSONEncoder(encoderCfg) + + atom := zap.NewAtomicLevelAt(parseLevel(cfg.Level)) + + var cores []zapcore.Core + for _, path := range cfg.OutputPaths { + cores = append(cores, zapcore.NewCore(encoder, rotateWriter(path, cfg.Rotate), atom)) + } + + core := teeCores(cores) + if len(extra) > 0 { + core = zapcore.NewTee(append([]zapcore.Core{core}, extra...)...) + } + + // Wire error output paths into zap's internal error sink (encoder/sync + // failures, etc.) so they respect the same rotation config as regular logs. + var errSinks []zapcore.WriteSyncer + for _, path := range cfg.ErrorOutputPaths { + errSinks = append(errSinks, rotateWriter(path, cfg.Rotate)) + } + + base := zap.New(core, + zap.ErrorOutput(zapcore.NewMultiWriteSyncer(errSinks...)), + zap.WrapCore(func(c zapcore.Core) zapcore.Core { + return zapcore.NewSamplerWithOptions(c, time.Second, 100, 100) + }), + ) + return &zapLogger{base: base, sugar: base.Sugar()}, nil +} + +// validateOutputPath fails fast on bad paths/permissions by trying to open the +// sink. stdout/stderr are always valid. +func validateOutputPath(path string) error { + if path == "stdout" || path == "stderr" { + return nil + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + if err != nil { + return err + } + return f.Close() +} + +// rotateWriter returns a WriteSyncer for the given path using lumberjack +// for file paths, os.Stdout/os.Stderr for console paths. +func rotateWriter(path string, rc *RotateConfig) zapcore.WriteSyncer { + switch path { + case "stdout": + return zapcore.AddSync(os.Stdout) + case "stderr": + return zapcore.AddSync(os.Stderr) + default: + return zapcore.AddSync(&lumberjack.Logger{ + Filename: path, + MaxSize: rc.MaxSize, + MaxAge: rc.MaxAge, + MaxBackups: rc.MaxBackups, + Compress: rc.Compress, + }) + } +} + +func teeCores(cores []zapcore.Core) zapcore.Core { + switch len(cores) { + case 0: + return zapcore.NewCore( + zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), + zapcore.AddSync(os.Stdout), + zap.NewAtomicLevelAt(zapcore.InfoLevel), + ) + case 1: + return cores[0] + default: + return zapcore.NewTee(cores...) + } +} + +// AsZapSugared returns the underlying zap SugaredLogger when available. +func AsZapSugared(l Logger) (*zap.SugaredLogger, bool) { + zl, ok := l.(*zapLogger) + if !ok { + return nil, false + } + return zl.sugar, true +} + +type zapLogger struct { + base *zap.Logger + sugar *zap.SugaredLogger +} + +func (l *zapLogger) Debugf(template string, args ...any) { + l.sugar.Debugf(template, args...) +} + +func (l *zapLogger) Infof(template string, args ...any) { + l.sugar.Infof(template, args...) +} + +func (l *zapLogger) Warnf(template string, args ...any) { + l.sugar.Warnf(template, args...) +} + +func (l *zapLogger) Errorf(template string, args ...any) { + l.sugar.Errorf(template, args...) +} + +func (l *zapLogger) With(fields ...Field) Logger { + if len(fields) == 0 { + return l + } + zfs := make([]zap.Field, 0, len(fields)) + for _, f := range fields { + zfs = append(zfs, zap.Any(f.Key, f.Value)) + } + nb := l.base.With(zfs...) + return &zapLogger{base: nb, sugar: nb.Sugar()} +} + +func (l *zapLogger) Named(name string) Logger { + nb := l.base.Named(name) + return &zapLogger{base: nb, sugar: nb.Sugar()} +} + +func (l *zapLogger) Sync() error { + return l.base.Sync() +} + +func parseLevel(level string) zapcore.Level { + switch strings.ToLower(level) { + case "debug": + return zapcore.DebugLevel + case "warn", "warning": + return zapcore.WarnLevel + case "error": + return zapcore.ErrorLevel + case "fatal": + return zapcore.FatalLevel + default: + return zapcore.InfoLevel + } +} + +func applyEnvOutputs(cfg Config) Config { + envVal := strings.TrimSpace(os.Getenv(envLogOutput)) + if len(cfg.OutputPaths) == 0 { + if envVal != "" { + cfg.OutputPaths = splitAndTrim(envVal) + } else { + cfg.OutputPaths = []string{"stdout"} + } + } + if len(cfg.ErrorOutputPaths) == 0 { + // Default error output matches output paths. + cfg.ErrorOutputPaths = cfg.OutputPaths + } + return cfg +} + +func splitAndTrim(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if v := strings.TrimSpace(p); v != "" { + out = append(out, v) + } + } + return out +} diff --git a/components/internal/safego/safe.go b/components/internal/safego/safe.go new file mode 100644 index 0000000..b2e8515 --- /dev/null +++ b/components/internal/safego/safe.go @@ -0,0 +1,55 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package safego + +import ( + "context" + "net/http" + "runtime" + + "github.com/alibaba/opensandbox/internal/logger" + runtimeutil "k8s.io/apimachinery/pkg/util/runtime" +) + +func InitPanicLogger(_ context.Context, log logger.Logger) { + runtimeutil.PanicHandlers = []func(context.Context, any){ + func(_ context.Context, r any) { + if r == http.ErrAbortHandler { // nolint:errorlint + return + } + + const size = 64 << 10 + stacktrace := make([]byte, size) + stacktrace = stacktrace[:runtime.Stack(stacktrace, false)] + if _, ok := r.(string); ok { + log.Errorf("Observed a panic: %s\n%s", r, stacktrace) + } else { + log.Errorf("Observed a panic: %#v (%v)\n%s", r, r, stacktrace) + } + }, + } +} + +func init() { + runtimeutil.ReallyCrash = false +} + +func Go(f func()) { + go func() { + defer runtimeutil.HandleCrash() + + f() + }() +} diff --git a/components/internal/safego/safe_test.go b/components/internal/safego/safe_test.go new file mode 100644 index 0000000..629b5eb --- /dev/null +++ b/components/internal/safego/safe_test.go @@ -0,0 +1,39 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package safego + +import ( + "context" + "sync" + "testing" + + "github.com/alibaba/opensandbox/internal/logger" +) + +func Test_Go(t *testing.T) { + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + + logg, _ := logger.New(logger.Config{}) + InitPanicLogger(ctx, logg) + + var wg sync.WaitGroup + wg.Add(1) + Go(func() { + defer wg.Done() + panic("I'm done") + }) + wg.Wait() +} diff --git a/components/internal/supervisor/README.md b/components/internal/supervisor/README.md new file mode 100644 index 0000000..694e37c --- /dev/null +++ b/components/internal/supervisor/README.md @@ -0,0 +1,162 @@ +# opensandbox-supervisor + +A lightweight process supervisor that wraps a single worker with restart backoff, lifecycle hooks, a crashloop circuit breaker, and a structured event log. Designed to run as a container `ENTRYPOINT` or as a child of another process; it does not assume PID 1 and performs no zombie reaping. + +## Usage + +``` +opensandbox-supervisor [flags] -- [worker-args...] +``` + +Everything after `--` is the worker command. The supervisor starts the worker, monitors it, and restarts it on unexpected exits. + +### Example (egress sidecar) + +```dockerfile +ENTRYPOINT ["/opt/opensandbox-egress/supervisor", \ + "--pre-start=/opt/opensandbox-egress/cleanup.sh", \ + "--name=egress", \ + "--grace-period=20s", \ + "--", \ + "/opt/opensandbox-egress/egress"] +``` + +## Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--pre-start` | _(none)_ | Executable to run before each worker launch (repeatable). No shell expansion; wrap in a script if needed. | +| `--post-exit` | _(none)_ | Executable to run after each worker exit (repeatable). Receives `WORKER_*` env vars. Failures are logged, not fatal. | +| `--event-log` | stderr | Path to JSONL event log file. Supports rotation via lumberjack. | +| `--backoff-min` | `1s` | Minimum restart backoff. | +| `--backoff-max` | `30s` | Maximum restart backoff (exponential growth capped here). | +| `--backoff-jitter` | `0.1` | Jitter fraction (±10%). Set to `0` to disable. | +| `--stable-after` | `60s` | Worker uptime after which backoff resets to minimum. | +| `--burst-window` | `5m` | Sliding window for crashloop detection. | +| `--burst-max` | `10` | Maximum launches allowed within `burst-window` before the breaker trips. | +| `--on-burst-exit` | `true` | `true`: supervisor exits non-zero when burst budget trips (lets kubelet react). `false`: keep retrying indefinitely. | +| `--grace-period` | `10s` | Time between SIGTERM and SIGKILL when shutting the worker down. | +| `--pre-start-timeout` | `30s` | Timeout for each pre-start hook execution. | +| `--post-exit-timeout` | `30s` | Timeout for each post-exit hook execution. | +| `--name` | _(basename of worker cmd)_ | Worker name shown in logs and events. | +| `--log-level` | `info` | Supervisor diagnostic log level (`debug`\|`info`\|`warn`\|`error`). | + +## Restart Behavior + +### Exponential Backoff + +When the worker exits unexpectedly, the supervisor sleeps before restarting: + +``` +1s → 2s → 4s → 8s → 16s → 30s → 30s → ... +``` + +Each delay is perturbed by ±`backoff-jitter` (default ±10%) to avoid thundering herds. After the worker has been alive at least `stable-after` (default 60 s), the backoff resets to `backoff-min`. + +### Crashloop Circuit Breaker + +A sliding-window counter tracks launches. If more than `burst-max` (default 10) launches occur within `burst-window` (default 5 min), the supervisor either: + +- **Exits non-zero** (`--on-burst-exit=true`, default) — surfacing the crashloop via Kubernetes pod status instead of silently retrying. +- **Continues retrying** (`--on-burst-exit=false`) — for environments without an outer restart supervisor. + +## Lifecycle Hooks + +### Pre-start hooks + +Run **before each worker launch**. A non-zero exit aborts that launch attempt and counts toward the crashloop budget. Use for cleanup tasks like reaping orphaned child processes from a previous crash. + +### Post-exit hooks + +Run **after the worker has been reaped**. Failures are logged but do not block the restart loop. Post-exit hooks run to completion even during shutdown (bounded by `--post-exit-timeout`) so cleanup paths are not aborted. + +Post-exit hooks receive these environment variables: + +| Variable | Description | +|----------|-------------| +| `WORKER_EXIT_CODE` | Worker's exit code (`-1` if not available) | +| `WORKER_SIGNAL` | Signal name if worker was signaled (e.g. `terminated`, `killed`) | +| `WORKER_DURATION_MS` | Wall-clock worker runtime in milliseconds | +| `WORKER_PID` | Worker's PID | +| `WORKER_ATTEMPT` | Launch attempt number (1-based) | + +## Graceful Shutdown + +On context cancellation (typically from `SIGTERM` or `SIGINT`): + +1. Supervisor sends `SIGTERM` to the worker. +2. Waits up to `--grace-period` for the worker to exit on its own. +3. Sends `SIGKILL` if the worker does not exit in time. + +### Signal Handling + +- The supervisor does **not** install `signal.Notify` itself; the caller (e.g. `cmd/supervisor/main.go`) translates OS signals into context cancellation. +- `SIGINT` and `SIGTERM` both result in `SIGTERM` to the worker. +- Other signals (`SIGHUP`, `SIGUSR1`, etc.) are **not forwarded**. Add forwarding in the caller if the worker needs them. + +### Process Group Isolation + +The worker is started with `Setpgid=true` on Unix so signals delivered to the supervisor's process group do not reach the worker by side channel. The supervisor signals the worker explicitly via its PID. + +## Structured Event Log + +One JSONL record per lifecycle event, written to stderr by default or to the file specified by `--event-log` (with automatic rotation). + +### Event Kinds + +| Event | When | Key Fields | +|-------|------|------------| +| `start` | Worker process launched | `pid`, `gen`, `attempt` | +| `exit` | Worker exited | `pid`, `gen`, `attempt`, `exit_code`, `signal`, `duration_ms`, `reason` | +| `prestart` | Pre-start hook ran | `hook`, `exit_code`, `duration_ms` | +| `postexit` | Post-exit hook ran | `hook`, `exit_code`, `duration_ms` | +| `backoff` | Sleeping before next restart | `sleep_ms`, `next_attempt` | +| `stable` | Worker uptime exceeded `stable-after`; backoff reset | `pid`, `gen`, `duration_ms`, `reset_backoff` | +| `burst_exit` | Crashloop budget exceeded | `attempts`, `window` | +| `shutdown` | Supervisor shutting down | `reason` | + +### Example Events + +```jsonl +{"ts":"2026-01-15T10:30:00Z","name":"egress","event":"start","pid":42,"gen":1,"attempt":1} +{"ts":"2026-01-15T10:30:00.15Z","name":"egress","event":"exit","pid":42,"gen":1,"attempt":1,"exit_code":1,"duration_ms":150,"reason":"crashed"} +{"ts":"2026-01-15T10:30:00.15Z","name":"egress","event":"backoff","sleep_ms":1000,"next_attempt":2} +{"ts":"2026-01-15T10:30:01.15Z","name":"egress","event":"prestart","hook":"cleanup.sh","exit_code":0,"duration_ms":50} +{"ts":"2026-01-15T10:30:01.2Z","name":"egress","event":"start","pid":43,"gen":2,"attempt":2} +``` + +### Exit Reasons + +| Reason | Meaning | +|--------|---------| +| `exited` | Worker exited with code 0 | +| `crashed` | Worker exited with non-zero code | +| `signaled` | Worker killed by signal | +| `shutdown` | Supervisor-initiated stop (context cancelled) | +| `launch_failed` | Worker binary could not be started | +| `no_processstate` | Unexpected: no process state available | + +## Library Usage + +The `internal/supervisor` package can be used programmatically: + +```go +import "github.com/alibaba/opensandbox/internal/supervisor" + +spec := supervisor.Spec{ + Name: "my-worker", + Cmd: "/usr/local/bin/worker", + Args: []string{"--config", "/etc/worker.toml"}, + PreStart: []supervisor.Hook{{Argv: []string{"/usr/local/bin/cleanup.sh"}}}, + BackoffMin: time.Second, + BackoffMax: 30 * time.Second, + GracePeriod: 15 * time.Second, +} + +ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +defer cancel() + +err := supervisor.Run(ctx, spec) +``` + +`Run` blocks until context cancellation or `ErrBurstExceeded`. Zero-valued fields receive sensible defaults (see Flags table above for values). diff --git a/components/internal/supervisor/backoff.go b/components/internal/supervisor/backoff.go new file mode 100644 index 0000000..948812d --- /dev/null +++ b/components/internal/supervisor/backoff.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "math/rand/v2" + "time" +) + +// nextBackoff returns the next sleep duration given the previous one. +// +// prev <= 0 returns min. Otherwise the previous value is doubled, clamped to +// [min, max], and perturbed by ±jitter*value. The result is clamped a second +// time so jitter cannot exceed max or go below 1ns. +func nextBackoff(prev, min, max time.Duration, jitter float64, rng func() float64) time.Duration { + if prev <= 0 { + return clampJitter(min, min, max, jitter, rng) + } + d := prev * 2 + if d < min { + d = min + } + if d > max { + d = max + } + return clampJitter(d, min, max, jitter, rng) +} + +func clampJitter(d, min, max time.Duration, jitter float64, rng func() float64) time.Duration { + if jitter <= 0 { + return d + } + span := float64(d) * jitter + // rng returns [0,1); shift to [-1,1). + delta := time.Duration((rng()*2 - 1) * span) + out := d + delta + if out < min { + out = min + } + if out > max { + out = max + } + if out < time.Nanosecond { + out = time.Nanosecond + } + return out +} + +// defaultRNG wraps math/rand/v2 for production use. +func defaultRNG() float64 { return rand.Float64() } diff --git a/components/internal/supervisor/backoff_test.go b/components/internal/supervisor/backoff_test.go new file mode 100644 index 0000000..8d8044e --- /dev/null +++ b/components/internal/supervisor/backoff_test.go @@ -0,0 +1,128 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "testing" + "time" +) + +// fixedRNG returns a constant value to make jitter deterministic. +func fixedRNG(v float64) func() float64 { return func() float64 { return v } } + +func TestNextBackoff_NoJitterDoublesAndClamps(t *testing.T) { + min := 1 * time.Second + max := 8 * time.Second + noJitter := fixedRNG(0.5) // would be zero delta anyway with jitter=0 + + cases := []struct { + prev time.Duration + want time.Duration + }{ + {0, min}, // initial + {-1 * time.Second, min}, // negative + {500 * time.Millisecond, min}, // below min after doubling -> clamp up + {1 * time.Second, 2 * time.Second}, + {2 * time.Second, 4 * time.Second}, + {4 * time.Second, 8 * time.Second}, + {8 * time.Second, max}, // would be 16s -> clamp to max + {100 * time.Second, max}, + } + for _, c := range cases { + got := nextBackoff(c.prev, min, max, 0, noJitter) + if got != c.want { + t.Errorf("nextBackoff(prev=%s) = %s, want %s", c.prev, got, c.want) + } + } +} + +func TestNextBackoff_JitterWithinBounds(t *testing.T) { + min := 1 * time.Second + max := 10 * time.Second + jitter := 0.5 + + // rng=0 -> delta = -1 * 0.5 * d = -50% + // rng=0.5 -> delta = 0 + // rng=1- -> delta ≈ +50% + // Approximate by checking the two extremes plus the midpoint. + for _, v := range []float64{0.0, 0.5, 0.9999} { + got := nextBackoff(2*time.Second, min, max, jitter, fixedRNG(v)) + // Base after doubling = 4s. jitter range ±2s. So [2s, 6s]. + if got < 2*time.Second || got > 6*time.Second { + t.Errorf("rng=%v: got %s, want in [2s, 6s]", v, got) + } + } +} + +func TestNextBackoff_JitterClampsToMax(t *testing.T) { + min := 1 * time.Second + max := 5 * time.Second + // Base = max after doubling. Positive jitter must not push above max. + got := nextBackoff(max, min, max, 0.5, fixedRNG(0.9999)) + if got > max { + t.Errorf("got %s > max %s", got, max) + } +} + +func TestNextBackoff_JitterClampsToMin(t *testing.T) { + min := 2 * time.Second + max := 10 * time.Second + // Base = min. Negative jitter must not push below min. + got := nextBackoff(0, min, max, 0.5, fixedRNG(0.0)) + if got < min { + t.Errorf("got %s < min %s", got, min) + } +} + +// Spec.BackoffJitter is *float64 specifically so callers can pass &zero to +// disable jitter; verify the underlying nextBackoff respects jitter=0. +func TestNextBackoff_JitterZeroDisablesJitter(t *testing.T) { + min := 1 * time.Second + max := 16 * time.Second + // With jitter=0, output must be the exact doubled value regardless of rng. + for _, rng := range []float64{0.0, 0.5, 0.9999} { + got := nextBackoff(2*time.Second, min, max, 0, fixedRNG(rng)) + if got != 4*time.Second { + t.Errorf("rng=%v: got %s, want exactly 4s", rng, got) + } + } +} + +func TestApplyDefaults_BackoffJitter(t *testing.T) { + cases := []struct { + name string + in *float64 + want float64 + }{ + {"nil applies default", nil, defaultBackoffJitter}, + {"zero stays zero", floatPtr(0), 0}, + {"explicit value preserved", floatPtr(0.25), 0.25}, + {"negative clamped to zero", floatPtr(-0.5), 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + s := Spec{Cmd: "/bin/true", BackoffJitter: c.in} + s.applyDefaults() + if s.BackoffJitter == nil { + t.Fatal("BackoffJitter still nil after applyDefaults") + } + if *s.BackoffJitter != c.want { + t.Errorf("BackoffJitter = %v, want %v", *s.BackoffJitter, c.want) + } + }) + } +} + +func floatPtr(v float64) *float64 { return &v } diff --git a/components/internal/supervisor/burst.go b/components/internal/supervisor/burst.go new file mode 100644 index 0000000..532a613 --- /dev/null +++ b/components/internal/supervisor/burst.go @@ -0,0 +1,73 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "time" +) + +// burstTracker counts launches within a sliding window. The ring is sized +// to BurstMax so we discard older entries automatically; exceeded() asks +// "are the BurstMax most-recent launches all within BurstWindow?". +type burstTracker struct { + max int + window time.Duration + now func() time.Time + ring []time.Time + idx int + filled int +} + +func newBurstTracker(max int, window time.Duration, now func() time.Time) *burstTracker { + if max < 1 { + max = 1 + } + return &burstTracker{ + max: max, + window: window, + now: now, + ring: make([]time.Time, max), + } +} + +func (b *burstTracker) record() { + b.ring[b.idx] = b.now() + b.idx = (b.idx + 1) % b.max + if b.filled < b.max { + b.filled++ + } +} + +// exceeded reports whether the oldest of the last BurstMax launches falls +// inside BurstWindow. With BurstMax=10 and window=5m, this triggers once 10 +// launches have all occurred within a 5-minute span. +func (b *burstTracker) exceeded() bool { + if b.filled < b.max { + return false + } + oldest := b.ring[b.idx] // next slot to overwrite = oldest entry + return b.now().Sub(oldest) <= b.window +} + +func (b *burstTracker) count() int { + cutoff := b.now().Add(-b.window) + n := 0 + for i := 0; i < b.filled; i++ { + if !b.ring[i].Before(cutoff) { + n++ + } + } + return n +} diff --git a/components/internal/supervisor/events.go b/components/internal/supervisor/events.go new file mode 100644 index 0000000..9d3762f --- /dev/null +++ b/components/internal/supervisor/events.go @@ -0,0 +1,97 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "encoding/json" + "io" + "sync" + "time" +) + +// Event kinds. Stable string values; downstream log pipelines may filter on them. +const ( + EventStart = "start" + EventExit = "exit" + EventPreStart = "prestart" + EventPostExit = "postexit" + EventBackoff = "backoff" + EventStable = "stable" + EventBurstExit = "burst_exit" + EventShutdown = "shutdown" +) + +// Event is one structured record in the supervisor's event log. Only set +// fields are emitted (omitempty everywhere) so different kinds share one type. +type Event struct { + TS time.Time `json:"ts"` + Name string `json:"name,omitempty"` + Event string `json:"event"` + PID int `json:"pid,omitempty"` + Gen uint64 `json:"gen,omitempty"` + Attempt int `json:"attempt,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Signal string `json:"signal,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Reason string `json:"reason,omitempty"` + SleepMS int64 `json:"sleep_ms,omitempty"` + NextAttempt int `json:"next_attempt,omitempty"` + Hook string `json:"hook,omitempty"` + Attempts int `json:"attempts,omitempty"` + Window string `json:"window,omitempty"` + Error string `json:"error,omitempty"` + ResetBackoff bool `json:"reset_backoff,omitempty"` +} + +// eventWriter serializes Event writes through a mutex; concurrent writers +// will not interleave bytes mid-line. +type eventWriter struct { + mu sync.Mutex + w io.Writer + name string + now func() time.Time +} + +func newEventWriter(w io.Writer, name string, now func() time.Time) *eventWriter { + return &eventWriter{w: w, name: name, now: now} +} + +// emit fills TS/Name and writes the event followed by a newline. Errors are +// returned to the caller so the supervisor can surface them; callers may +// choose to ignore (event logging must not abort the main loop). +func (ew *eventWriter) emit(e Event) error { + if ew == nil || ew.w == nil { + return nil + } + if e.TS.IsZero() { + e.TS = ew.now() + } + if e.Name == "" { + e.Name = ew.name + } + buf, err := json.Marshal(e) + if err != nil { + return err + } + buf = append(buf, '\n') + ew.mu.Lock() + defer ew.mu.Unlock() + _, err = ew.w.Write(buf) + return err +} + +// intPtr is a small helper for Event.ExitCode (which is *int so 0 vs unset +// is distinguishable in JSON output). +func intPtr(v int) *int { return &v } diff --git a/components/internal/supervisor/events_test.go b/components/internal/supervisor/events_test.go new file mode 100644 index 0000000..c286ab2 --- /dev/null +++ b/components/internal/supervisor/events_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "bufio" + "bytes" + "encoding/json" + "strings" + "sync" + "testing" + "time" +) + +func TestEventWriter_Roundtrip(t *testing.T) { + var buf bytes.Buffer + fixed := time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC) + ew := newEventWriter(&buf, "egress", func() time.Time { return fixed }) + + if err := ew.emit(Event{Event: EventStart, PID: 1234, Attempt: 1, Gen: 1}); err != nil { + t.Fatal(err) + } + if err := ew.emit(Event{Event: EventExit, PID: 1234, Gen: 1, ExitCode: intPtr(0), DurationMS: 1500}); err != nil { + t.Fatal(err) + } + + sc := bufio.NewScanner(strings.NewReader(buf.String())) + + if !sc.Scan() { + t.Fatal("expected first line") + } + var e1 Event + if err := json.Unmarshal(sc.Bytes(), &e1); err != nil { + t.Fatal(err) + } + if e1.Event != EventStart || e1.PID != 1234 || e1.Name != "egress" || !e1.TS.Equal(fixed) { + t.Fatalf("e1 unexpected: %+v", e1) + } + + if !sc.Scan() { + t.Fatal("expected second line") + } + var e2 Event + if err := json.Unmarshal(sc.Bytes(), &e2); err != nil { + t.Fatal(err) + } + if e2.Event != EventExit || e2.ExitCode == nil || *e2.ExitCode != 0 || e2.DurationMS != 1500 { + t.Fatalf("e2 unexpected: %+v", e2) + } +} + +func TestEventWriter_ConcurrentWritesDoNotInterleave(t *testing.T) { + var buf bytes.Buffer + ew := newEventWriter(&buf, "n", time.Now) + + const goroutines = 16 + const perGoroutine = 64 + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < perGoroutine; j++ { + if err := ew.emit(Event{Event: EventBackoff, SleepMS: 1}); err != nil { + t.Errorf("emit: %v", err) + } + } + }() + } + wg.Wait() + + sc := bufio.NewScanner(strings.NewReader(buf.String())) + count := 0 + for sc.Scan() { + var e Event + if err := json.Unmarshal(sc.Bytes(), &e); err != nil { + t.Fatalf("line %d not valid JSON: %v\nline=%q", count, err, sc.Text()) + } + count++ + } + if count != goroutines*perGoroutine { + t.Fatalf("got %d events, want %d", count, goroutines*perGoroutine) + } +} + +func TestEventWriter_NilSafe(t *testing.T) { + var ew *eventWriter + if err := ew.emit(Event{Event: EventStart}); err != nil { + t.Fatal(err) + } +} diff --git a/components/internal/supervisor/pgid_other.go b/components/internal/supervisor/pgid_other.go new file mode 100644 index 0000000..081c90a --- /dev/null +++ b/components/internal/supervisor/pgid_other.go @@ -0,0 +1,21 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !unix + +package supervisor + +import "os/exec" + +func applyChildPgid(cmd *exec.Cmd) {} diff --git a/components/internal/supervisor/pgid_unix.go b/components/internal/supervisor/pgid_unix.go new file mode 100644 index 0000000..1317a10 --- /dev/null +++ b/components/internal/supervisor/pgid_unix.go @@ -0,0 +1,33 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build unix + +package supervisor + +import ( + "os/exec" + "syscall" +) + +// applyChildPgid puts the worker in its own process group so that signals +// sent to the supervisor's pgid (e.g. by a parent process tree) do not +// accidentally reach the worker. The supervisor sends explicit signals to +// the worker pid when it intends to. +func applyChildPgid(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} diff --git a/components/internal/supervisor/spec.go b/components/internal/supervisor/spec.go new file mode 100644 index 0000000..c51944f --- /dev/null +++ b/components/internal/supervisor/spec.go @@ -0,0 +1,218 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package supervisor runs a single child process under a restart loop with +// exponential backoff, pre-start / post-exit hooks, a crashloop circuit +// breaker, and a structured event log. It is intentionally scoped to one +// worker per supervisor; multi-process supervision is delegated to higher +// layers (e.g. Kubernetes pods). +// +// Signal handling. Callers control the supervisor's lifecycle via the +// context passed to Run: cancelling ctx triggers a SIGTERM to the worker +// followed by SIGKILL after GracePeriod. This package does NOT install any +// signal.Notify itself; the caller (e.g. cmd/supervisor) is responsible for +// translating OS signals into context cancellation. As a result: +// +// - SIGINT and SIGTERM, when wired to ctx by the caller, both result in +// SIGTERM being sent to the worker (the supervisor does not preserve +// which signal triggered shutdown). +// - SIGHUP, SIGUSR1, SIGUSR2, SIGWINCH, SIGQUIT, and similar +// application-level signals are NOT forwarded to the worker. If the +// worker needs them (e.g. config reload, log rotate, tty resize), the +// caller must add forwarding around Run. +// +// Process group. The worker is started with Setpgid=true on Unix so that +// signals delivered to the supervisor's process group do not reach the +// worker by side channel. The supervisor signals the worker explicitly via +// its PID. +// +// PID 1 / reaping. The supervisor does not call PR_SET_CHILD_SUBREAPER and +// does not reap arbitrary children, only the worker it launched. If the +// worker spawns its own descendants and is killed, those descendants are +// reparented per usual kernel rules. Run this supervisor as PID 1 only when +// the worker itself does not orphan grandchildren. +package supervisor + +import ( + "io" + "os" + "path/filepath" + "time" + + "github.com/alibaba/opensandbox/internal/logger" +) + +// Hook describes an auxiliary process invoked around the worker lifecycle. +// Argv[0] is the executable; remaining entries are arguments. Hooks are +// invoked directly (no shell); wrap in a shell script if expansion is needed. +type Hook struct { + Argv []string +} + +// Spec configures a supervisor run. +type Spec struct { + // Name identifies the supervised worker in logs and events. Defaults to + // basename(Cmd). + Name string + + // Cmd is the worker executable path. Required. + Cmd string + Args []string + Env []string // defaults to os.Environ() + Dir string // working directory; empty = inherit + + // PreStart hooks run before each worker launch. A non-zero exit aborts + // the launch and counts toward the crashloop budget. + PreStart []Hook + // PostExit hooks run after the worker has been reaped. Failures are + // logged but do not block the restart loop. + PostExit []Hook + PreStartTimeout time.Duration // default 30s + PostExitTimeout time.Duration // default 30s + + // Backoff controls inter-restart sleep. Sleep grows exponentially from + // BackoffMin to BackoffMax with ±*BackoffJitter*prev jitter. After the + // worker has been alive at least StableAfter, the backoff resets. + BackoffMin time.Duration // default 1s + BackoffMax time.Duration // default 30s + // BackoffJitter is a *float64 so callers can distinguish "unset" + // (defaults to 0.1) from "explicitly disabled" (pass &zero). Negative + // values are clamped to 0. + BackoffJitter *float64 + StableAfter time.Duration // default 60s + + // Crashloop circuit breaker. If more than BurstMax launches occur + // within BurstWindow, the supervisor either returns (OnBurstExit=true, + // default) so the surrounding runtime can react, or continues looping. + BurstWindow time.Duration // default 5m + BurstMax int // default 10 + // OnBurstExit selects burst behavior. Default true. + // A *bool lets callers override the non-zero default; nil means default. + OnBurstExit *bool + + // GracePeriod is how long SIGTERM is given to the worker on shutdown + // before SIGKILL. Default 10s. + GracePeriod time.Duration + + // EventLog receives one JSON object per line. nil => os.Stderr. + EventLog io.Writer + + // WorkerStdout / WorkerStderr forward the worker's standard streams. + // nil defaults to the supervisor's own streams. + WorkerStdout io.Writer + WorkerStderr io.Writer + + // Logger receives free-form supervisor diagnostics. nil => a no-op logger. + Logger logger.Logger + + // Clock is injected for tests; nil => real clock. + Clock Clock +} + +// Clock abstracts time for tests. Implementations must be goroutine-safe. +type Clock interface { + Now() time.Time + // After is identical to time.After. + After(d time.Duration) <-chan time.Time +} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } +func (realClock) After(d time.Duration) <-chan time.Time { return time.After(d) } + +// Defaults applied to zero-valued fields. +const ( + defaultBackoffMin = time.Second + defaultBackoffMax = 30 * time.Second + defaultBackoffJitter = 0.1 + defaultStableAfter = 60 * time.Second + defaultBurstWindow = 5 * time.Minute + defaultBurstMax = 10 + defaultGracePeriod = 10 * time.Second + defaultPreStartTimeout = 30 * time.Second + defaultPostExitTimeout = 30 * time.Second +) + +func (s *Spec) applyDefaults() { + if s.Name == "" && s.Cmd != "" { + s.Name = filepath.Base(s.Cmd) + } + if s.Env == nil { + s.Env = os.Environ() + } + if s.BackoffMin <= 0 { + s.BackoffMin = defaultBackoffMin + } + if s.BackoffMax <= 0 { + s.BackoffMax = defaultBackoffMax + } + if s.BackoffMax < s.BackoffMin { + s.BackoffMax = s.BackoffMin + } + if s.BackoffJitter == nil { + v := defaultBackoffJitter + s.BackoffJitter = &v + } else if *s.BackoffJitter < 0 { + v := 0.0 + s.BackoffJitter = &v + } + if s.StableAfter <= 0 { + s.StableAfter = defaultStableAfter + } + if s.BurstWindow <= 0 { + s.BurstWindow = defaultBurstWindow + } + if s.BurstMax <= 0 { + s.BurstMax = defaultBurstMax + } + if s.OnBurstExit == nil { + v := true + s.OnBurstExit = &v + } + if s.GracePeriod <= 0 { + s.GracePeriod = defaultGracePeriod + } + if s.PreStartTimeout <= 0 { + s.PreStartTimeout = defaultPreStartTimeout + } + if s.PostExitTimeout <= 0 { + s.PostExitTimeout = defaultPostExitTimeout + } + if s.EventLog == nil { + s.EventLog = os.Stderr + } + if s.WorkerStdout == nil { + s.WorkerStdout = os.Stdout + } + if s.WorkerStderr == nil { + s.WorkerStderr = os.Stderr + } + if s.Logger == nil { + s.Logger = noopLogger{} + } + if s.Clock == nil { + s.Clock = realClock{} + } +} + +type noopLogger struct{} + +func (noopLogger) Debugf(string, ...any) {} +func (noopLogger) Infof(string, ...any) {} +func (noopLogger) Warnf(string, ...any) {} +func (noopLogger) Errorf(string, ...any) {} +func (noopLogger) With(...logger.Field) logger.Logger { return noopLogger{} } +func (noopLogger) Named(string) logger.Logger { return noopLogger{} } +func (noopLogger) Sync() error { return nil } diff --git a/components/internal/supervisor/stop.go b/components/internal/supervisor/stop.go new file mode 100644 index 0000000..25ec9cf --- /dev/null +++ b/components/internal/supervisor/stop.go @@ -0,0 +1,41 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "os" + "syscall" + "time" +) + +// gracefulStop sends SIGTERM, then either waits for the worker to exit +// (signalled via done) or escalates to SIGKILL after grace. cmd.Wait in the +// caller is what actually reaps the process; this only signals. +// +// Wall-clock time is used deliberately (rather than the injected Clock): +// the worker is a real OS process, so the grace timeout must elapse in +// real time regardless of how tests fast-forward Spec.Clock. +func gracefulStop(p *os.Process, grace time.Duration, done <-chan struct{}) { + if p == nil { + return + } + _ = p.Signal(syscall.SIGTERM) + select { + case <-time.After(grace): + _ = p.Kill() + case <-done: + // Worker exited within grace; no kill needed. + } +} diff --git a/components/internal/supervisor/supervisor.go b/components/internal/supervisor/supervisor.go new file mode 100644 index 0000000..325eaa5 --- /dev/null +++ b/components/internal/supervisor/supervisor.go @@ -0,0 +1,280 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strconv" + "sync/atomic" + "syscall" + "time" +) + +// ErrBurstExceeded is returned by Run when the crashloop budget is exhausted +// and Spec.OnBurstExit is true. +var ErrBurstExceeded = errors.New("supervisor: crashloop budget exceeded") + +// Run supervises the worker described by spec until ctx is cancelled or the +// crashloop budget is exhausted. It returns ctx.Err() on graceful shutdown, +// ErrBurstExceeded on burst exit, or a setup error if Spec is invalid. +func Run(ctx context.Context, spec Spec) error { + if spec.Cmd == "" { + return errors.New("supervisor: Spec.Cmd is required") + } + spec.applyDefaults() + + ew := newEventWriter(spec.EventLog, spec.Name, spec.Clock.Now) + starts := newBurstTracker(spec.BurstMax, spec.BurstWindow, spec.Clock.Now) + + var ( + gen uint64 + attempt int + backoff time.Duration + ) + + shutdown := func() error { + _ = ew.emit(Event{Event: EventShutdown, Reason: "ctx cancelled"}) + return ctx.Err() + } + + for { + if ctx.Err() != nil { + return shutdown() + } + + attempt++ + curGen := atomic.AddUint64(&gen, 1) + + // Pre-start hooks. Failure aborts the launch, counts toward burst. + if hookErr := runHooks(ctx, spec.PreStart, spec.PreStartTimeout, spec.Env, ew, EventPreStart); hookErr != nil { + spec.Logger.Warnf("supervisor: pre-start hook failed: %v", hookErr) + starts.record() + if exitOnBurst(starts, spec, ew, attempt) { + return ErrBurstExceeded + } + backoff = sleepBackoff(ctx, spec, ew, backoff, attempt+1) + if backoff < 0 { + return shutdown() + } + continue + } + + // Launch worker. Worker run-duration is measured in wall-clock time + // because the child is a real process; fake clocks (used in tests + // for backoff control) would otherwise report 0 and never trip the + // StableAfter threshold. + starts.record() + runStart := time.Now() + cmd := exec.Command(spec.Cmd, spec.Args...) + cmd.Env = spec.Env + cmd.Dir = spec.Dir + cmd.Stdout = spec.WorkerStdout + cmd.Stderr = spec.WorkerStderr + applyChildPgid(cmd) + + if err := cmd.Start(); err != nil { + spec.Logger.Errorf("supervisor: launch failed: %v", err) + _ = ew.emit(Event{ + Event: EventExit, + Gen: curGen, + Attempt: attempt, + Reason: "launch_failed", + Error: err.Error(), + }) + if exitOnBurst(starts, spec, ew, attempt) { + return ErrBurstExceeded + } + backoff = sleepBackoff(ctx, spec, ew, backoff, attempt+1) + if backoff < 0 { + return shutdown() + } + continue + } + + pid := cmd.Process.Pid + spec.Logger.Infof("supervisor: worker started (pid=%d, gen=%d, attempt=%d)", pid, curGen, attempt) + _ = ew.emit(Event{Event: EventStart, PID: pid, Gen: curGen, Attempt: attempt}) + + // Graceful-shutdown goroutine: on ctx cancel, send SIGTERM then SIGKILL + // unless the worker exits within GracePeriod on its own. + stopped := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + gracefulStop(cmd.Process, spec.GracePeriod, stopped) + case <-stopped: + } + }() + + waitErr := cmd.Wait() + close(stopped) + runDur := time.Since(runStart) + + exitCode, sigName, reason := classifyExit(cmd, waitErr, ctx.Err() != nil) + spec.Logger.Infof("supervisor: worker exited (pid=%d, gen=%d, dur=%s, code=%d, signal=%s, reason=%s)", + pid, curGen, runDur, exitCode, sigName, reason) + _ = ew.emit(Event{ + Event: EventExit, + PID: pid, + Gen: curGen, + Attempt: attempt, + ExitCode: intPtr(exitCode), + Signal: sigName, + DurationMS: runDur.Milliseconds(), + Reason: reason, + }) + + // Stable: reset backoff if the worker stayed alive long enough. + if runDur >= spec.StableAfter { + if backoff > 0 { + _ = ew.emit(Event{ + Event: EventStable, PID: pid, Gen: curGen, ResetBackoff: true, + DurationMS: runDur.Milliseconds(), + }) + } + backoff = 0 + } + + // Post-exit hooks. Receive context env. Errors are logged, not fatal. + // Build hookEnv into a fresh slice so we never mutate spec.Env's + // underlying array (which `append(spec.Env, ...)` may do when + // cap > len). + hookEnv := make([]string, 0, len(spec.Env)+5) + hookEnv = append(hookEnv, spec.Env...) + hookEnv = append(hookEnv, + "WORKER_EXIT_CODE="+strconv.Itoa(exitCode), + "WORKER_SIGNAL="+sigName, + "WORKER_DURATION_MS="+strconv.FormatInt(runDur.Milliseconds(), 10), + "WORKER_PID="+strconv.Itoa(pid), + "WORKER_ATTEMPT="+strconv.Itoa(attempt), + ) + // Post-exit hooks must run to completion even during shutdown so + // cleanup (iptables / nft / temp files) is not aborted. Use a + // detached ctx bounded by PostExitTimeout instead of ctx. + if hookErr := runHooks(context.Background(), spec.PostExit, spec.PostExitTimeout, hookEnv, ew, EventPostExit); hookErr != nil { + spec.Logger.Warnf("supervisor: post-exit hook failed: %v", hookErr) + } + + if ctx.Err() != nil { + return shutdown() + } + + if exitOnBurst(starts, spec, ew, attempt) { + return ErrBurstExceeded + } + + backoff = sleepBackoff(ctx, spec, ew, backoff, attempt+1) + if backoff < 0 { + return shutdown() + } + } +} + +// sleepBackoff computes the next backoff, emits a backoff event, and sleeps. +// Returns the slept duration, or -1 if ctx was cancelled mid-sleep. +func sleepBackoff(ctx context.Context, spec Spec, ew *eventWriter, prev time.Duration, nextAttempt int) time.Duration { + d := nextBackoff(prev, spec.BackoffMin, spec.BackoffMax, *spec.BackoffJitter, defaultRNG) + _ = ew.emit(Event{Event: EventBackoff, SleepMS: d.Milliseconds(), NextAttempt: nextAttempt}) + select { + case <-ctx.Done(): + return -1 + case <-spec.Clock.After(d): + } + return d +} + +// exitOnBurst checks the burst tracker. Returns true if Run should bail out. +func exitOnBurst(b *burstTracker, spec Spec, ew *eventWriter, attempt int) bool { + if !b.exceeded() { + return false + } + _ = ew.emit(Event{ + Event: EventBurstExit, + Attempts: b.count(), + Window: spec.BurstWindow.String(), + Attempt: attempt, + Reason: "crashloop budget exceeded", + }) + return *spec.OnBurstExit +} + +// classifyExit extracts the worker's exit code and (if killed) the signal +// name, plus a coarse reason string for the event log. +func classifyExit(cmd *exec.Cmd, waitErr error, ctxCancelled bool) (exitCode int, sigName, reason string) { + if cmd.ProcessState == nil { + return -1, "", "no_processstate" + } + exitCode = cmd.ProcessState.ExitCode() + if ws, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + sigName = ws.Signal().String() + } + switch { + case ctxCancelled: + reason = "shutdown" + case waitErr == nil: + reason = "exited" + case sigName != "": + reason = "signaled" + default: + reason = "crashed" + } + return +} + +// runHooks invokes each hook sequentially. The first non-zero exit (or +// timeout / launch error) is recorded and returned; subsequent hooks still +// run so cleanup paths complete. +func runHooks(ctx context.Context, hooks []Hook, timeout time.Duration, env []string, ew *eventWriter, kind string) error { + var firstErr error + for _, h := range hooks { + if len(h.Argv) == 0 { + continue + } + hctx, cancel := context.WithTimeout(ctx, timeout) + cmd := exec.CommandContext(hctx, h.Argv[0], h.Argv[1:]...) + cmd.Env = env + start := time.Now() + err := cmd.Run() + dur := time.Since(start) + cancel() + + ev := Event{ + Event: kind, + Hook: h.Argv[0], + DurationMS: dur.Milliseconds(), + } + exitCode := 0 + if err != nil { + ev.Error = err.Error() + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } else { + exitCode = -1 + } + } else if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + ev.ExitCode = intPtr(exitCode) + _ = ew.emit(ev) + + if err != nil && firstErr == nil { + firstErr = fmt.Errorf("%s hook %q: %w", kind, h.Argv[0], err) + } + } + return firstErr +} diff --git a/components/internal/supervisor/supervisor_test.go b/components/internal/supervisor/supervisor_test.go new file mode 100644 index 0000000..0117c66 --- /dev/null +++ b/components/internal/supervisor/supervisor_test.go @@ -0,0 +1,391 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package supervisor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +// The test binary re-execs itself to act as a fake child process. The mode +// is selected by env so callers do not need a separate helper binary on disk. +const childModeEnv = "SUPERVISOR_TEST_CHILD" + +func TestMain(m *testing.M) { + switch os.Getenv(childModeEnv) { + case "": + os.Exit(m.Run()) + case "exit0": + os.Exit(0) + case "exit1": + os.Exit(1) + case "crash-after-100ms": + time.Sleep(100 * time.Millisecond) + os.Exit(2) + case "sleep-then-exit0": + dur, _ := time.ParseDuration(os.Getenv("CHILD_SLEEP")) + time.Sleep(dur) + os.Exit(0) + case "hang-until-sigterm": + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGTERM) + <-c + os.Exit(0) + case "hang-ignore-sigterm": + signal.Ignore(syscall.SIGTERM) + time.Sleep(time.Hour) + os.Exit(99) + case "write-stdout": + fmt.Println("hello from child") + os.Exit(0) + default: + os.Exit(99) + } +} + +// childCmd builds the args needed to re-invoke the test binary in a given +// child mode. The supervisor sees this as a normal external command. +func childCmd(mode string, extraEnv ...string) (cmd string, args []string, env []string) { + env = append(os.Environ(), childModeEnv+"="+mode) + env = append(env, extraEnv...) + return os.Args[0], []string{"-test.run=TestMain"}, env +} + +// fakeClock implements Clock with controllable time advancement. After is +// implemented as a real-time short sleep so we don't have to build a full +// scheduler; tests use sub-second backoffs. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func newFakeClock() *fakeClock { + return &fakeClock{t: time.Unix(0, 0)} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch +} + +func parseEvents(t *testing.T, buf *bytes.Buffer) []Event { + t.Helper() + var out []Event + sc := bufio.NewScanner(strings.NewReader(buf.String())) + for sc.Scan() { + if len(sc.Bytes()) == 0 { + continue + } + var e Event + if err := json.Unmarshal(sc.Bytes(), &e); err != nil { + t.Fatalf("bad event JSON: %v\nline=%q", err, sc.Text()) + } + out = append(out, e) + } + return out +} + +func filterEvents(events []Event, kind string) []Event { + var out []Event + for _, e := range events { + if e.Event == kind { + out = append(out, e) + } + } + return out +} + +// baseSpec returns a Spec with short timeouts suitable for tests. +func baseSpec(buf *bytes.Buffer, clk Clock) Spec { + false_ := false + jitter := 0.01 + return Spec{ + Name: "test-worker", + BackoffMin: 10 * time.Millisecond, + BackoffMax: 20 * time.Millisecond, + BackoffJitter: &jitter, + StableAfter: 50 * time.Millisecond, + BurstWindow: time.Second, + BurstMax: 1000, // disabled by default for most tests + OnBurstExit: &false_, + GracePeriod: 200 * time.Millisecond, + EventLog: buf, + WorkerStdout: &bytes.Buffer{}, + WorkerStderr: &bytes.Buffer{}, + Clock: clk, + } +} + +func TestRun_CrashRestartsThenShutdown(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("exit1") + + ctx, cancel := context.WithCancel(context.Background()) + // Let it crash a couple of times then cancel. + go func() { + time.Sleep(300 * time.Millisecond) + cancel() + }() + err := Run(ctx, spec) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } + + events := parseEvents(t, &buf) + starts := filterEvents(events, EventStart) + exits := filterEvents(events, EventExit) + if len(starts) < 2 { + t.Fatalf("expected ≥2 starts, got %d", len(starts)) + } + if len(exits) < 2 { + t.Fatalf("expected ≥2 exits, got %d", len(exits)) + } + // The last exit may be a SIGTERM-on-shutdown race where the child gets + // killed before its os.Exit(1) runs. Only assert on completed exits. + for i, e := range exits { + if e.Reason == "shutdown" || e.Reason == "signaled" { + continue + } + if e.ExitCode == nil || *e.ExitCode != 1 { + code := "" + if e.ExitCode != nil { + code = strconv.Itoa(*e.ExitCode) + } + t.Errorf("exit[%d] code = %s reason=%q, want exit code 1", i, code, e.Reason) + } + } + if len(filterEvents(events, EventBackoff)) < 1 { + t.Errorf("expected backoff events") + } + if len(filterEvents(events, EventShutdown)) != 1 { + t.Errorf("expected one shutdown event") + } +} + +func TestRun_GracefulShutdownOnSIGTERM(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("hang-until-sigterm") + spec.GracePeriod = 2 * time.Second + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(150 * time.Millisecond) + cancel() + }() + err := Run(ctx, spec) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } + events := parseEvents(t, &buf) + exits := filterEvents(events, EventExit) + if len(exits) != 1 { + t.Fatalf("expected 1 exit, got %d", len(exits)) + } + if exits[0].ExitCode == nil || *exits[0].ExitCode != 0 { + t.Errorf("expected clean exit 0 after SIGTERM, got code=%v signal=%q", exits[0].ExitCode, exits[0].Signal) + } +} + +func TestRun_SIGKILLAfterGracePeriod(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("hang-ignore-sigterm") + spec.GracePeriod = 250 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(150 * time.Millisecond) + cancel() + }() + err := Run(ctx, spec) + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } + events := parseEvents(t, &buf) + exits := filterEvents(events, EventExit) + if len(exits) != 1 { + t.Fatalf("expected 1 exit, got %d", len(exits)) + } + if exits[0].Signal != syscall.SIGKILL.String() { + t.Errorf("expected SIGKILL, got %q", exits[0].Signal) + } +} + +func TestRun_StableResetsBackoff(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + // Sleep child for longer than StableAfter so we mark stable. + spec.Cmd, spec.Args, spec.Env = childCmd("sleep-then-exit0", "CHILD_SLEEP=100ms") + spec.StableAfter = 50 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) + defer cancel() + _ = Run(ctx, spec) + + events := parseEvents(t, &buf) + if len(filterEvents(events, EventStable)) == 0 { + t.Fatalf("expected at least one stable event\nall events: %+v", events) + } +} + +func TestRun_BurstExitTriggers(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("exit1") + spec.BurstMax = 3 + spec.BurstWindow = 5 * time.Second + true_ := true + spec.OnBurstExit = &true_ + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := Run(ctx, spec) + if !errors.Is(err, ErrBurstExceeded) { + t.Fatalf("want ErrBurstExceeded, got %v", err) + } + events := parseEvents(t, &buf) + if len(filterEvents(events, EventBurstExit)) != 1 { + t.Fatalf("expected 1 burst_exit event\nevents: %+v", events) + } +} + +func TestRun_PreStartFailureCountsAsBurst(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("exit0") + // A pre-start hook that always fails (false on POSIX). + spec.PreStart = []Hook{{Argv: []string{"/bin/false"}}} + spec.BurstMax = 2 + spec.BurstWindow = 5 * time.Second + true_ := true + spec.OnBurstExit = &true_ + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := Run(ctx, spec) + if !errors.Is(err, ErrBurstExceeded) { + t.Fatalf("want ErrBurstExceeded, got %v", err) + } + events := parseEvents(t, &buf) + ps := filterEvents(events, EventPreStart) + if len(ps) < 2 { + t.Fatalf("expected ≥2 prestart events\nevents: %+v", events) + } + for i, e := range ps { + if e.ExitCode == nil || *e.ExitCode == 0 { + t.Errorf("prestart[%d] expected non-zero exit, got %v", i, e.ExitCode) + } + } + // No worker should have launched. + if got := filterEvents(events, EventStart); len(got) != 0 { + t.Errorf("expected zero start events, got %d", len(got)) + } +} + +func TestRun_PostExitHookRunsWithWorkerEnv(t *testing.T) { + var buf bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("exit0") + + // Use a hook that writes its env to a file we can inspect. + tmp, err := os.CreateTemp(t.TempDir(), "posthook-*.env") + if err != nil { + t.Fatal(err) + } + tmp.Close() + script := fmt.Sprintf("#!/bin/sh\nenv | grep ^WORKER_ > %s\n", tmp.Name()) + hookPath := tmp.Name() + ".sh" + if err := os.WriteFile(hookPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + spec.PostExit = []Hook{{Argv: []string{hookPath}}} + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + _ = Run(ctx, spec) + + got, err := os.ReadFile(tmp.Name()) + if err != nil { + t.Fatal(err) + } + // The script truncates the file each invocation, so we see only the + // last hook run's env. Assert the env var keys are present; the value + // may be the shutdown iteration's (e.g. WORKER_EXIT_CODE=-1). + needles := []string{"WORKER_EXIT_CODE=", "WORKER_PID=", "WORKER_ATTEMPT=", "WORKER_DURATION_MS="} + for _, n := range needles { + if !strings.Contains(string(got), n) { + t.Errorf("hook env missing %q\nfile=\n%s", n, string(got)) + } + } +} + +func TestRun_WorkerStdoutForwarded(t *testing.T) { + var buf bytes.Buffer + var stdout bytes.Buffer + spec := baseSpec(&buf, newFakeClock()) + spec.Cmd, spec.Args, spec.Env = childCmd("write-stdout") + spec.WorkerStdout = &stdout + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + _ = Run(ctx, spec) + + if !strings.Contains(stdout.String(), "hello from child") { + t.Errorf("expected child stdout, got %q", stdout.String()) + } +} + +func TestRun_RejectsEmptyCmd(t *testing.T) { + err := Run(context.Background(), Spec{}) + if err == nil { + t.Fatal("expected error for empty Cmd") + } + if !strings.Contains(err.Error(), "Cmd") { + t.Errorf("unexpected error: %v", err) + } +} + +// Sanity: parse a duration into a string and back to verify the suite runs +// when triggered by the real go test entry (not the re-exec path). +func TestSanity(t *testing.T) { + _, err := strconv.Atoi("1") + if err != nil { + t.Fatal(err) + } +} diff --git a/components/internal/telemetry/attrs.go b/components/internal/telemetry/attrs.go new file mode 100644 index 0000000..eb65780 --- /dev/null +++ b/components/internal/telemetry/attrs.go @@ -0,0 +1,65 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import ( + "os" + "strings" + + "go.opentelemetry.io/otel/attribute" +) + +type SharedAttrsEnvConfig struct { + SandboxIDEnv string + ExtraAttrsEnv string + SandboxAttr string +} + +func SharedAttrsFromEnv(cfg SharedAttrsEnvConfig) []attribute.KeyValue { + attrKey := strings.TrimSpace(cfg.SandboxAttr) + if attrKey == "" { + attrKey = "sandbox_id" + } + + var kvs []attribute.KeyValue + if id := strings.TrimSpace(os.Getenv(cfg.SandboxIDEnv)); id != "" { + kvs = append(kvs, attribute.String(attrKey, id)) + } + return AppendAttrsFromKeyValuePairs(kvs, os.Getenv(cfg.ExtraAttrsEnv)) +} + +func AppendAttrsFromKeyValuePairs(kvs []attribute.KeyValue, raw string) []attribute.KeyValue { + raw = strings.TrimSpace(raw) + if raw == "" { + return kvs + } + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + i := strings.IndexByte(part, '=') + if i <= 0 || i == len(part)-1 { + continue + } + key := strings.TrimSpace(part[:i]) + value := strings.TrimSpace(part[i+1:]) + if key == "" { + continue + } + kvs = append(kvs, attribute.String(key, value)) + } + return kvs +} diff --git a/components/internal/telemetry/attrs_test.go b/components/internal/telemetry/attrs_test.go new file mode 100644 index 0000000..eabbee0 --- /dev/null +++ b/components/internal/telemetry/attrs_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package telemetry + +import "testing" + +func TestAppendAttrsFromKeyValuePairs(t *testing.T) { + t.Parallel() + + attrs := AppendAttrsFromKeyValuePairs(nil, "tenant=t1, zone=cn-hz,invalid,noeq=, =blank") + if len(attrs) != 2 { + t.Fatalf("attrs len = %d, want 2", len(attrs)) + } + + if string(attrs[0].Key) != "tenant" || attrs[0].Value.AsString() != "t1" { + t.Fatalf("unexpected first attr: %v", attrs[0]) + } + if string(attrs[1].Key) != "zone" || attrs[1].Value.AsString() != "cn-hz" { + t.Fatalf("unexpected second attr: %v", attrs[1]) + } +} + +func TestSharedAttrsFromEnv(t *testing.T) { + t.Setenv("OSBX_SANDBOX_ID", "sb-123") + t.Setenv("OSBX_EXTRA_ATTRS", "tenant=t1,env=dev") + + attrs := SharedAttrsFromEnv(SharedAttrsEnvConfig{ + SandboxIDEnv: "OSBX_SANDBOX_ID", + ExtraAttrsEnv: "OSBX_EXTRA_ATTRS", + SandboxAttr: "sandbox_id", + }) + if len(attrs) != 3 { + t.Fatalf("attrs len = %d, want 3", len(attrs)) + } +} diff --git a/components/internal/telemetry/init.go b/components/internal/telemetry/init.go new file mode 100644 index 0000000..4128320 --- /dev/null +++ b/components/internal/telemetry/init.go @@ -0,0 +1,185 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package telemetry provides shared OpenTelemetry OTLP metrics setup for OpenSandbox binaries. +package telemetry + +import ( + "context" + "errors" + "net" + "os" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" + "go.opentelemetry.io/otel/metric/noop" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +// Config controls OTLP metrics export. Endpoints follow standard OTEL env vars; see metricsEnabled. +type Config struct { + ServiceName string + ResourceAttributes []attribute.KeyValue + RegisterMetrics func() error +} + +const ( + envOTLPMetricsEndpoint = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" + envOTLPEndpoint = "OTEL_EXPORTER_OTLP_ENDPOINT" + envHostIP = "HOST_IP" + otlpHTTPPort = "4318" + hostInfoPath = "/etc/hostinfo" +) + +// Init sets a noop TracerProvider, optionally MeterProvider with OTLP HTTP exporter. +// Shutdown must be called on exit. +func Init(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error) { + if strings.TrimSpace(cfg.ServiceName) == "" { + return nil, errors.New("telemetry: ServiceName is required") + } + + otel.SetTracerProvider(tracenoop.NewTracerProvider()) + + res, err := buildResource(ctx, cfg.ServiceName, cfg.ResourceAttributes) + if err != nil { + return nil, err + } + + var ( + mp *sdkmetric.MeterProvider + shutdownFuncs []func(context.Context) error + ) + + if metricsEnabled() { + opts := append(metricsClientOptions(), + otlpmetrichttp.WithTemporalitySelector(deltaTemporalitySelector), + ) + mexp, err := otlpmetrichttp.New(ctx, opts...) + if err != nil { + return nil, err + } + reader := sdkmetric.NewPeriodicReader(mexp) + mp = sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(reader), + ) + otel.SetMeterProvider(mp) + shutdownFuncs = append(shutdownFuncs, mp.Shutdown) + if cfg.RegisterMetrics != nil { + if err := cfg.RegisterMetrics(); err != nil { + _ = mp.Shutdown(ctx) + otel.SetMeterProvider(noop.NewMeterProvider()) + return nil, err + } + } + } + + shutdown = func(ctx context.Context) error { + var errs []error + for i := len(shutdownFuncs) - 1; i >= 0; i-- { + if err := shutdownFuncs[i](ctx); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) + } + return shutdown, nil +} + +func buildResource(ctx context.Context, serviceName string, extra []attribute.KeyValue) (*resource.Resource, error) { + opts := []resource.Option{ + resource.WithAttributes(semconv.ServiceName(serviceName)), + } + if len(extra) > 0 { + opts = append(opts, resource.WithAttributes(extra...)) + } + return resource.New(ctx, opts...) +} + +// Endpoint precedence: OTEL_EXPORTER_OTLP_*_ENDPOINT -> HOST_IP -> /etc/hostinfo. +func metricsEnabled() bool { + if otlpEndpointFromEnv() != "" { + return true + } + _, ok := resolveNodeIP() + return ok +} + +func metricsClientOptions() []otlpmetrichttp.Option { + if otlpEndpointFromEnv() != "" { + return nil + } + ip, ok := resolveNodeIP() + if !ok { + return nil + } + return []otlpmetrichttp.Option{ + otlpmetrichttp.WithEndpoint(net.JoinHostPort(ip, otlpHTTPPort)), + otlpmetrichttp.WithInsecure(), + } +} + +func otlpEndpointFromEnv() string { + return firstEndpoint(os.Getenv(envOTLPMetricsEndpoint), os.Getenv(envOTLPEndpoint)) +} + +func resolveNodeIP() (string, bool) { + if ip := strings.TrimSpace(os.Getenv(envHostIP)); ip != "" && net.ParseIP(ip) != nil { + return ip, true + } + return readHostInfoIP(hostInfoPath) +} + +func readHostInfoIP(path string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if net.ParseIP(line) != nil { + return line, true + } + } + return "", false +} + +func firstEndpoint(primary, fallback string) string { + if s := strings.TrimSpace(primary); s != "" { + return s + } + return strings.TrimSpace(fallback) +} + +// deltaTemporalitySelector returns delta temporality for monotonic instruments +// (Counter, Histogram, ObservableCounter). Gauges and UpDownCounters keep +// the default cumulative semantics. +func deltaTemporalitySelector(kind sdkmetric.InstrumentKind) metricdata.Temporality { + switch kind { + case sdkmetric.InstrumentKindCounter, + sdkmetric.InstrumentKindHistogram: + return metricdata.DeltaTemporality + default: + return metricdata.CumulativeTemporality + } +} diff --git a/components/internal/version/version.go b/components/internal/version/version.go new file mode 100644 index 0000000..ac919c5 --- /dev/null +++ b/components/internal/version/version.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "fmt" + "runtime" +) + +// Package values are typically overridden at build time via -ldflags. +var ( + // Version is the component version. + Version = "dirty" + // BuildTime is when the binary was built. + BuildTime = "assigned-at-build-time" + // GitCommit is the commit id used to build the binary. + GitCommit = "assigned-at-build-time" +) + +// EchoVersion prints build info for the given component name (e.g. "OpenSandbox Ingress", "OpenSandbox Execd"). +// All components can use this by passing their display name. +func EchoVersion(componentName string) { + fmt.Println("=====================================================") + fmt.Printf(" %s\n", componentName) + fmt.Println("-----------------------------------------------------") + fmt.Printf(" Version : %s\n", Version) + fmt.Printf(" Git Commit : %s\n", GitCommit) + fmt.Printf(" Build Time : %s\n", BuildTime) + fmt.Printf(" Go Version : %s\n", runtime.Version()) + fmt.Printf(" Platform : %s/%s\n", runtime.GOOS, runtime.GOARCH) + fmt.Println("=====================================================") +} diff --git a/docs/.nvmrc b/docs/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/docs/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..f862c68 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,302 @@ +import { defineConfig } from "vitepress"; + +export default defineConfig({ + title: "OpenSandbox", + description: "Universal Sandbox Infrastructure for AI Applications", + cleanUrls: true, + lastUpdated: true, + base: process.env.DOCS_BASE || "/", + ignoreDeadLinks: [/^https?:\/\/localhost/], + srcExclude: ["README.md"], + + head: [ + ["link", { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }], + [ + "meta", + { property: "og:title", content: "OpenSandbox Documentation" }, + ], + [ + "meta", + { + property: "og:description", + content: "Universal Sandbox Infrastructure for AI Applications", + }, + ], + ], + + themeConfig: { + logo: "/images/logo.svg", + siteTitle: "OpenSandbox", + + nav: [ + { text: "Getting Started", link: "/getting-started/" }, + { text: "Guides", link: "/guides/credential-vault" }, + { + text: "Reference", + items: [ + { text: "SDKs", link: "/sdks/" }, + { text: "API Specs", link: "/api/" }, + { text: "CLI", link: "/cli/" }, + { text: "Components", link: "/components/" }, + { text: "Kubernetes", link: "/kubernetes/" }, + { text: "Migration Guides", link: "/reference/execd-path-migration" }, + ], + }, + { text: "Examples", link: "/examples/" }, + { text: "Community", link: "/community/contributing" }, + ], + + sidebar: { + "/getting-started/": [ + { + text: "Getting Started", + items: [ + { text: "Quick Start", link: "/getting-started/" }, + { text: "Installation", link: "/getting-started/installation" }, + { + text: "Configuration", + link: "/getting-started/configuration", + }, + ], + }, + { + text: "Next Steps", + items: [ + { text: "Architecture", link: "/architecture/" }, + { text: "Guides", link: "/guides/credential-vault" }, + { text: "SDKs", link: "/sdks/" }, + ], + }, + ], + + "/architecture/": [ + { + text: "Architecture", + items: [ + { text: "Overview", link: "/architecture/" }, + { + text: "Single-Host Network", + link: "/architecture/single-host-network", + }, + { + text: "Network Isolation", + link: "/architecture/network-isolation", + }, + ], + }, + ], + + "/guides/": [ + { + text: "Guides", + items: [ + { text: "Credential Vault", link: "/guides/credential-vault" }, + { text: "Secure Container", link: "/guides/secure-container" }, + { text: "Pause & Resume", link: "/guides/pause-resume" }, + { text: "Windows Sandbox", link: "/guides/windows-sandbox" }, + ], + }, + ], + + "/sdks/": [ + { + text: "Sandbox SDKs", + collapsed: false, + items: [ + { text: "Overview", link: "/sdks/" }, + { text: "Python", link: "/sdks/python" }, + { text: "JavaScript", link: "/sdks/javascript" }, + { text: "Kotlin", link: "/sdks/kotlin" }, + { text: "Go", link: "/sdks/go" }, + { text: "C#", link: "/sdks/csharp" }, + ], + }, + { + text: "Code Interpreter SDKs", + collapsed: false, + items: [ + { text: "Python", link: "/sdks/code-interpreter/python" }, + { + text: "JavaScript", + link: "/sdks/code-interpreter/javascript", + }, + { text: "Kotlin", link: "/sdks/code-interpreter/kotlin" }, + { text: "C#", link: "/sdks/code-interpreter/csharp" }, + ], + }, + { + text: "MCP", + collapsed: false, + items: [{ text: "MCP Server", link: "/sdks/mcp" }], + }, + ], + + "/components/": [ + { + text: "Components", + items: [ + { text: "Overview", link: "/components/" }, + { text: "Server", link: "/components/server" }, + { text: "Execd", link: "/components/execd" }, + { text: "Ingress", link: "/components/ingress" }, + { text: "Egress", link: "/components/egress" }, + ], + }, + ], + + "/kubernetes/": [ + { + text: "Kubernetes", + items: [ + { text: "Overview", link: "/kubernetes/" }, + { text: "Deployment", link: "/kubernetes/deployment" }, + ], + }, + ], + + "/api/": [ + { + text: "API Reference", + items: [{ text: "OpenAPI Specs", link: "/api/" }], + }, + ], + + "/cli/": [ + { + text: "CLI", + items: [{ text: "Reference", link: "/cli/" }], + }, + ], + + "/examples/": [ + { + text: "Examples", + items: [{ text: "Overview", link: "/examples/" }], + }, + { + text: "Coding Agents", + collapsed: false, + items: [ + { text: "Claude Code", link: "/examples/claude-code" }, + { text: "Gemini CLI", link: "/examples/gemini-cli" }, + { text: "Codex CLI", link: "/examples/codex-cli" }, + { text: "Qwen Code", link: "/examples/qwen-code" }, + { text: "Kimi CLI", link: "/examples/kimi-cli" }, + { text: "LangGraph", link: "/examples/langgraph" }, + { text: "Google ADK", link: "/examples/google-adk" }, + { text: "OpenClaw", link: "/examples/openclaw" }, + { text: "NullClaw", link: "/examples/nullclaw" }, + ], + }, + { + text: "Browser & Desktop", + collapsed: false, + items: [ + { text: "Chrome", link: "/examples/chrome" }, + { text: "Playwright", link: "/examples/playwright" }, + { text: "Desktop", link: "/examples/desktop" }, + { text: "VS Code", link: "/examples/vscode" }, + ], + }, + { + text: "Core Usage", + collapsed: false, + items: [ + { text: "Code Interpreter", link: "/examples/code-interpreter" }, + { text: "AIO Sandbox", link: "/examples/aio-sandbox" }, + { text: "Agent Sandbox", link: "/examples/agent-sandbox" }, + { text: "Windows", link: "/examples/windows" }, + { text: "RL Training", link: "/examples/rl-training" }, + ], + }, + { + text: "Storage", + collapsed: false, + items: [ + { + text: "Host Volume Mount", + link: "/examples/host-volume-mount", + }, + { + text: "Docker PVC Volume", + link: "/examples/docker-pvc-volume-mount", + }, + { + text: "Docker OSSFS Volume", + link: "/examples/docker-ossfs-volume-mount", + }, + { + text: "Kubernetes PVC", + link: "/examples/kubernetes-pvc-volume-mount", + }, + ], + }, + ], + + "/community/": [ + { + text: "Community", + items: [ + { text: "Contributing", link: "/community/contributing" }, + { text: "Code of Conduct", link: "/community/code-of-conduct" }, + { + text: "Enhancement Proposals", + link: "/community/oseps", + }, + ], + }, + { + text: "Releases", + items: [ + { + text: "Release Automation", + link: "/community/release-automation", + }, + { + text: "Release Verification", + link: "/community/release-verification", + }, + ], + }, + ], + + "/reference/": [ + { + text: "Reference", + items: [ + { + text: "Execd Path Migration", + link: "/reference/execd-path-migration", + }, + ], + }, + ], + }, + + editLink: { + pattern: + "https://github.com/opensandbox-group/OpenSandbox/edit/main/docs/:path", + text: "Edit this page on GitHub", + }, + + socialLinks: [ + { + icon: "github", + link: "https://github.com/opensandbox-group/OpenSandbox", + }, + ], + + footer: { + message: "Released under the Apache 2.0 License.", + copyright: "Copyright © 2024-present OpenSandbox Contributors", + }, + + search: { + provider: "local", + }, + + outline: { + level: [2, 3], + }, + }, +}); diff --git a/docs/.vitepress/theme/Layout.vue b/docs/.vitepress/theme/Layout.vue new file mode 100644 index 0000000..930162c --- /dev/null +++ b/docs/.vitepress/theme/Layout.vue @@ -0,0 +1,74 @@ + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..6fb1c5c --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,10 @@ +import DefaultTheme from "vitepress/theme"; +import Layout from "./Layout.vue"; +import "./styles.css"; + +export default { + extends: DefaultTheme, + // Layout.vue redirects legacy /zh/* and overview/* URLs to the post-#1090 + // structure before the default 404 renders. + Layout, +}; diff --git a/docs/.vitepress/theme/styles.css b/docs/.vitepress/theme/styles.css new file mode 100644 index 0000000..7ae8974 --- /dev/null +++ b/docs/.vitepress/theme/styles.css @@ -0,0 +1,77 @@ +:root { + --vp-c-brand-1: #2563eb; + --vp-c-brand-2: #1d4ed8; + --vp-c-brand-3: #1e40af; + --vp-c-brand-soft: rgba(37, 99, 235, 0.14); +} + +.dark { + --vp-c-brand-1: #3b82f6; + --vp-c-brand-2: #60a5fa; + --vp-c-brand-3: #93bbfd; + --vp-c-brand-soft: rgba(59, 130, 246, 0.16); +} + +.VPFeature { + border: 1px solid var(--vp-c-divider); + border-radius: 14px; + transition: border-color 0.25s, box-shadow 0.25s; +} + +.VPFeature:hover { + border-color: var(--vp-c-brand-1); +} + +.vp-doc blockquote { + border-left: 3px solid var(--vp-c-brand-1); +} + +/* Scenario cards grid */ +.scenario-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin: 16px 0 6px; +} + +.vp-doc .scenario-card { + display: block; + border: 1px solid var(--vp-c-divider); + border-radius: 12px; + padding: 14px; + text-decoration: none; + color: inherit; + background: var(--vp-c-bg-soft); + transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.vp-doc .scenario-card:hover { + border-color: var(--vp-c-brand-1); + transform: translateY(-2px); + box-shadow: 0 4px 12px var(--vp-c-brand-soft); + text-decoration: none; +} + +.vp-doc .scenario-card h3 { + margin: 0 0 8px; + font-size: 16px; + text-decoration: none; +} + +.vp-doc .scenario-card p { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--vp-c-text-2); + text-decoration: none; +} + +/* Code group enhancements */ +.vp-code-group .tabs label { + font-size: 13px; +} + +/* Footer styling */ +.VPFooter { + border-top: 1px solid var(--vp-c-divider); +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d959ff6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# OpenSandbox Docs Site + +This directory hosts the VitePress site for OpenSandbox. + +## Local development + +```bash +nvm use 22 +cd docs +pnpm install +pnpm docs:dev +``` + +## Build + +```bash +nvm use 22 +cd docs +pnpm install +pnpm docs:build +``` + +## Notes + +- Site content is maintained directly under `docs/` — not auto-generated from monorepo READMEs. +- See `AGENTS.md` → "Documentation Rules" for content ownership and conventions. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..cb68e2a --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,186 @@ +--- +title: API Specifications +description: OpenAPI specification documents defining the complete API interfaces and data models for OpenSandbox. +--- + +# OpenSandbox API Specifications + +This section contains the OpenAPI specification documents for the OpenSandbox project, defining the complete API interfaces and data models. Use the server base URLs defined in each spec (for example, `http://localhost:8080/v1` for the lifecycle API, `http://localhost:44772` for execd, and `http://localhost:18080` for egress) when constructing requests. + +## Specification Files + +### 1. sandbox-lifecycle.yml + +**Sandbox Lifecycle Management API** + +Defines the complete lifecycle interfaces for creating, managing, and destroying sandbox environments from container images or snapshots. + +**Core Features:** +- **Sandbox Management**: Create, list, query, and delete sandbox instances with metadata filters and pagination +- **State Control**: Pause and resume sandbox execution +- **Lifecycle States**: Supports transitions across Pending -> Running -> Pausing -> Paused -> Stopping -> Terminated, and error handling with `Failed` +- **Resource & Runtime Configuration**: Specify CPU/memory/GPU resource limits, image startup `entrypoint`, optional `secureAccess`, environment variables, and opaque `extensions` +- **Image Support**: Create sandboxes from public or private registries, including registry auth +- **Timeout Management**: Optional `timeout` on creation (omit or set to `null` to disable automatic expiration) with explicit renewal via API +- **Endpoint Access**: Retrieve public access endpoints for services running inside sandboxes, including required headers when secured access is enabled +- **Snapshot Management**: Create snapshots from sandboxes, list snapshots, and delete snapshots + +**Main Endpoints (base path `/v1`):** +- `POST /sandboxes` - Create a sandbox from an image or snapshot with timeout and resource limits +- `GET /sandboxes` - List sandboxes with state/metadata filters and pagination +- `GET /sandboxes/{sandboxId}` - Get full sandbox details (including startup source and entrypoint) +- `DELETE /sandboxes/{sandboxId}` - Delete a sandbox +- `POST /sandboxes/{sandboxId}/snapshots` - Create a snapshot from a sandbox +- `GET /snapshots` - List snapshots with optional sandbox filtering and pagination +- `GET /snapshots/{snapshotId}` - Get snapshot state and metadata +- `DELETE /snapshots/{snapshotId}` - Delete a snapshot +- `POST /sandboxes/{sandboxId}/pause` - Pause a sandbox (asynchronous) +- `POST /sandboxes/{sandboxId}/resume` - Resume a paused sandbox +- `POST /sandboxes/{sandboxId}/renew-expiration` - Renew sandbox expiration (TTL) +- `PATCH /sandboxes/{sandboxId}/metadata` - Patch sandbox metadata (JSON Merge Patch, RFC 7396) +- `GET /sandboxes/{sandboxId}/endpoints/{port}` - Get an access endpoint for a service port + +**Authentication:** +- HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key` +- Environment Variable: `OPEN_SANDBOX_API_KEY` (for SDK clients) + +### 2. diagnostic-api.yml + +**Sandbox Diagnostics API** + +Defines best-effort troubleshooting descriptors for sandbox diagnostic logs and events. The descriptors either embed plain-text diagnostic content inline or return a download URL for the content. This spec does not define a structured audit or observability model. + +**Main Endpoints (base path `/v1`):** +- `GET /sandboxes/{sandboxId}/diagnostics/logs` - Retrieve a diagnostic log content descriptor for an optional scope +- `GET /sandboxes/{sandboxId}/diagnostics/events` - Retrieve a diagnostic event content descriptor for an optional scope + +**Authentication:** +- HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key` +- Environment Variable: `OPEN_SANDBOX_API_KEY` (for SDK clients) + +### 3. execd-api.yaml + +**Code Execution API Inside Sandbox** + +Defines interfaces for executing code, commands, and file operations within sandbox environments, providing complete code interpreter and filesystem management capabilities. All endpoints require the `X-EXECD-ACCESS-TOKEN` header. + +**Core Features:** +- **Code Execution**: Stateful code execution supporting Python, JavaScript, and other languages with context lifecycle management +- **Command Execution**: Shell command execution with foreground/background modes and polling endpoints for status/output +- **File Operations**: Complete CRUD operations for files and directories +- **Real-time Streaming**: Real-time output streaming via SSE (Server-Sent Events) +- **System Monitoring**: Real-time monitoring of CPU and memory metrics +- **Access Control**: Token-based API authentication via `X-EXECD-ACCESS-TOKEN` + +**Main Endpoint Categories:** + +**Health Check:** +- `GET /ping` - Service health check + +**Code Interpreter:** +- `GET /code/contexts` - List active code execution contexts (filterable by language) +- `DELETE /code/contexts` - Delete all contexts for a language +- `DELETE /code/contexts/{context_id}` - Delete a specific context +- `POST /code/context` - Create a code execution context +- `POST /code` - Execute code in a context (streaming output) +- `DELETE /code` - Interrupt code execution + +**Command Execution:** +- `POST /command` - Execute shell command (streaming output) +- `DELETE /command` - Interrupt command execution +- `GET /command/status/{id}` - Get foreground/background command status +- `GET /command/{id}/logs` - Fetch accumulated stdout/stderr for a background command + +**Bash Session:** +- `POST /session` - Create a bash session +- `POST /session/{sessionId}/run` - Run command in a bash session (streaming output) +- `DELETE /session/{sessionId}` - Delete a bash session + +**Filesystem:** +- `GET /files/info` - Get metadata for files +- `DELETE /files` - Delete files (not directories) +- `POST /files/permissions` - Change file permissions +- `POST /files/mv` - Move/rename files +- `GET /files/search` - Search files (supports glob patterns) +- `POST /files/replace` - Batch replace file content +- `POST /files/upload` - Upload files (multipart) +- `GET /files/download` - Download files (supports range requests) + +**Directory Operations:** +- `GET /directories/list` - List directory contents with optional depth control +- `POST /directories` - Create directories with permissions (mkdir -p semantics) +- `DELETE /directories` - Recursively delete directories + +**System Metrics:** +- `GET /metrics` - Get system resource metrics +- `GET /metrics/watch` - Watch system metrics in real-time (SSE stream) + +**Isolated Execution (base path `/v1/isolated`):** +- `POST /session` - Create an isolated bash session +- `GET /capabilities` - Get isolator capabilities +- `GET /session/{sessionId}` - Get isolated session state +- `DELETE /session/{sessionId}` - Delete an isolated session +- `POST /session/{sessionId}/run` - Run code in an isolated session (SSE streaming) +- `GET /session/{sessionId}/diff` - Download upper directory diff +- `POST /session/{sessionId}/commit` - Commit upper changes to workspace +- `GET /session/{sessionId}/files/info` - Get file information +- `GET /session/{sessionId}/files/download` - Download a file +- `POST /session/{sessionId}/files/upload` - Upload a file +- `DELETE /session/{sessionId}/files` - Remove files +- `POST /session/{sessionId}/files/mv` - Rename or move files +- `POST /session/{sessionId}/files/permissions` - Change file permissions +- `POST /session/{sessionId}/files/replace` - Replace file content +- `GET /session/{sessionId}/files/search` - Search files +- `GET /session/{sessionId}/directories/list` - List directory contents +- `POST /session/{sessionId}/directories` - Create directories +- `DELETE /session/{sessionId}/directories` - Delete directories + +### 4. egress-api.yaml + +**Sandbox Egress Runtime API** + +Defines the runtime egress policy interface exposed directly by the [egress sidecar](/components/egress) +inside a sandbox. Unlike lifecycle operations, this API is reached by first resolving +the sandbox endpoint for the egress port and then calling the sidecar endpoint directly. + +**Core Features:** +- **Policy Inspection**: Retrieve the currently enforced egress policy and derived runtime mode +- **Policy Mutation**: Patch egress rules at runtime using sidecar merge semantics +- **Direct Sidecar Access**: Access via sandbox endpoint resolution instead of server-side lifecycle forwarding +- **Optional Sidecar Auth**: Supports endpoint-specific headers when the egress sidecar requires auth + +**Main Endpoints:** +- `GET /policy` - Get the current egress policy +- `PATCH /policy` - Merge new egress rules into the current policy +- `DELETE /policy` - Remove specific egress rules from the current policy by target + +## Technical Features + +### Streaming Output (Server-Sent Events) + +Code execution and command execution interfaces use SSE for real-time streaming output, supporting the following event types: +- `init` - Initialization event +- `status` - Status update +- `stdout` / `stderr` - Standard output/error streams +- `result` - Execution result +- `execution_complete` - Execution completed +- `execution_count` - Execution count +- `error` - Error information + +### Resource Limits + +Supports flexible resource configuration (similar to Kubernetes): +```json +{ + "cpu": "500m", + "memory": "512Mi", + "gpu": "1" +} +``` + +### File Permissions + +Supports Unix-style file permission management: +- Owner +- Group +- Permission mode values such as 644 or 755 diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..c7d90eb --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,400 @@ +--- +title: Architecture +description: OpenSandbox architecture overview covering client SDKs, lifecycle control plane, runtime backends, and data-plane components. +--- + +# OpenSandbox Architecture + +OpenSandbox is a general-purpose sandbox platform for AI applications. It provides client SDKs and tools, protocol definitions, a lifecycle control plane, Docker and Kubernetes runtime backends, and in-sandbox execution components for commands, files, code interpreters, browser automation, desktop environments, and training workloads. + +This document describes the current repository architecture and the main boundaries between public contracts, server implementation, runtime providers, and sandbox data-plane components. + +## Architecture Overview + +![OpenSandbox Architecture](../public/images/architecture-overview.svg) + +OpenSandbox is organized around six practical surfaces: + +1. **Client surface** - SDKs, the `osb` CLI, and the MCP server used by applications, agents, and operators. +2. **Protocol surface** - OpenAPI contracts under `specs/` for lifecycle, diagnostics, in-sandbox execution, and egress policy. +3. **Lifecycle control plane** - the FastAPI server under `server/` that authenticates requests, validates config, persists server-managed records, and delegates lifecycle work to a configured runtime service. +4. **Runtime backends** - Docker for local and single-host deployments, and Kubernetes through workload providers such as BatchSandbox and `kubernetes-sigs/agent-sandbox`. +5. **Sandbox data plane** - the user workload container plus the injected `execd` daemon, optional Jupyter/code-interpreter runtime, volumes, and optional egress sidecar. +6. **Network and security plane** - endpoint resolution, server proxying, Kubernetes ingress gateway routing, secure endpoint access, egress policy enforcement, resource limits, and secure container runtimes. + +The split is intentional: SDKs and tools should depend on the public contracts, the server should own lifecycle orchestration, runtime providers should own platform-specific resource creation, and `execd`/egress should own operations that happen from inside the sandbox network and filesystem namespace. + +## 1. Client Surface + +The client surface is the developer-facing entry point for OpenSandbox. + +### 1.1 Sandbox SDKs + +The sandbox SDKs wrap lifecycle operations and in-sandbox operations behind language-native APIs: + +- Python: `sdks/sandbox/python` +- JavaScript/TypeScript: `sdks/sandbox/javascript` +- Java/Kotlin: `sdks/sandbox/kotlin` +- C#/.NET: `sdks/sandbox/csharp` +- Go: `sdks/sandbox/go` + +Common capabilities include: + +- Create, list, inspect, pause, resume, renew, and delete sandboxes. +- Resolve service endpoints for sandbox ports. +- Execute commands with streamed output and background status/log polling. +- Manage files and directories. +- Read resource metrics from `execd`. +- Inspect or patch runtime egress policy when an egress sidecar is attached. + +Generated OpenAPI clients live beside handwritten adapters. Generated code handles ordinary request/response APIs; handwritten layers cover SDK ergonomics, streaming, transport lifecycle, error mapping, and high-level models. + +### 1.2 Code Interpreter SDKs + +The code-interpreter SDKs build on the sandbox SDKs and `execd` code execution APIs. They manage code execution contexts and expose language-oriented code execution helpers. + +The official code-interpreter image is under `sandboxes/code-interpreter/`. It provides Python, Java, Node.js, and Go runtimes, and Jupyter kernels for Python, Java, TypeScript/JavaScript, Go, and Bash. Exact language versions are image-controlled and selected through environment variables such as `PYTHON_VERSION`, `JAVA_VERSION`, `NODE_VERSION`, and `GO_VERSION`. + +### 1.3 CLI and MCP + +The `osb` CLI under `cli/` is a terminal interface for day-to-day sandbox operations: + +- `osb sandbox`: lifecycle and endpoint management +- `osb command`: command execution, background logs, and shell sessions +- `osb file`: file and directory operations +- `osb egress`: runtime egress policy inspection and mutation +- `osb devops`: low-level diagnostics +- `osb skills`: OpenSandbox-specific agent skill installation + +The MCP server under `sdks/mcp/sandbox/python` exposes focused sandbox lifecycle, command, and text-file tools to MCP-capable clients such as Claude Code and Cursor. + +## 2. Protocol Surface + +OpenSandbox treats `specs/` as the public contract source of truth. + +### 2.1 Lifecycle API + +`specs/sandbox-lifecycle.yml` defines the lifecycle API, served by the server with the base path `/v1`. + +Main resource groups: + +- **Sandboxes**: create from an image or snapshot, list, get, delete, pause, resume, renew expiration, and resolve port endpoints. +- **Snapshots**: create a persistent snapshot from a sandbox, list snapshots, get snapshot state, and delete snapshots. + +Important request features: + +- `image` or `snapshotId` startup source. +- `entrypoint`, environment variables, metadata, and opaque `extensions`. +- `resourceLimits` for CPU, memory, GPU, and future resource keys. +- `platform` constraints. +- `volumes` for host paths, platform-managed named volumes/PVCs, and OSSFS. +- `networkPolicy` for egress sidecar configuration. +- `secureAccess` for Kubernetes ingress gateway deployments that require endpoint credentials. + +### 2.2 Diagnostics API + +`specs/diagnostic-api.yml` defines best-effort diagnostic descriptors for sandbox logs and events. The server also exposes practical DevOps diagnostics routes that return plain text for operators and AI troubleshooting workflows. + +### 2.3 Execd API + +`specs/execd-api.yaml` defines the in-sandbox execution API exposed by `components/execd/`. + +Main capabilities: + +- Health check: `GET /ping` +- Code contexts and execution: `/code/contexts`, `/code/context`, `/code` +- Bash sessions: `/session` +- Commands: `/command`, command status, and background command logs +- Files and directories: `/files/*`, `/directories` +- Metrics: `/metrics`, `/metrics/watch` + +Command and code execution use Server-Sent Events for streaming output. The current `execd` implementation also includes interactive PTY WebSocket endpoints under `/pty` for long-lived shell sessions. + +### 2.4 Egress API + +`specs/egress-api.yaml` defines the runtime policy API exposed directly by the egress sidecar: + +- `GET /policy` +- `PATCH /policy` + +The API is reached by resolving the sandbox endpoint for the egress sidecar port. When sidecar authentication is enabled, callers must include the endpoint headers returned by the lifecycle endpoint resolution API. + +## 3. Lifecycle Control Plane + +The lifecycle server under `server/` is a FastAPI application. It owns request validation, API-key authentication, server configuration, lifecycle orchestration, endpoint formatting, diagnostics, and server-managed persistence. + +### 3.1 Server Structure + +Key packages: + +- `opensandbox_server/main.py`: app startup, middleware, router registration, runtime validation, and renew-intent startup. +- `opensandbox_server/api/`: lifecycle routes, proxy routes, pool routes, diagnostics routes, and request/response schemas. +- `opensandbox_server/services/`: lifecycle service interfaces and Docker/Kubernetes implementations. +- `opensandbox_server/services/k8s/`: Kubernetes workload providers, endpoint resolution, volume/egress helpers, informer support, and provider-specific mapping. +- `opensandbox_server/repositories/`: persistence adapters, currently used for server-managed snapshot records. +- `opensandbox_server/integrations/renew_intent/`: optional auto-renew-on-access integration. +- `opensandbox_server/middleware/`: API-key authentication and request ID middleware. + +### 3.2 Runtime Service Selection + +The server selects exactly one lifecycle implementation from `[runtime].type`: + +- `docker` -> `DockerSandboxService` +- `kubernetes` -> `KubernetesSandboxService` + +Both implementations satisfy the same `SandboxService` interface, so API routes stay thin and delegate behavior to services. Runtime-specific details stay behind the service boundary. + +### 3.3 Server Persistence + +The `[store]` configuration selects the server-managed metadata store. The default is SQLite at `~/.opensandbox/opensandbox.db`. Snapshot metadata is the first persisted server resource; future persistent records should reuse the same repository boundary. + +### 3.4 Server Proxy and Endpoint Resolution + +The lifecycle endpoint API returns the reachable address for a service port inside a sandbox. Depending on runtime and configuration, the endpoint may be: + +- A Docker host/bridge mapped endpoint. +- A Kubernetes ingress gateway endpoint. +- A server-proxied URL under `/sandboxes/{sandboxId}/proxy/{port}` when `use_server_proxy=true`. + +The server proxy supports HTTP and WebSocket traffic and is also integrated with optional renew-on-access behavior. + +## 4. Runtime Backends + +### 4.1 Docker Runtime + +The Docker runtime is the local and single-host backend. It talks directly to the Docker daemon and manages containers, timers, labels, volumes, ports, optional sidecars, and snapshots. + +Core responsibilities: + +- Pull public or private images, including per-request registry authentication. +- Create containers with CPU, memory, GPU, platform, capability, AppArmor, seccomp, PID, and secure-runtime settings. +- Stage the `execd` binary from `[runtime].execd_image` into the sandbox and install a bootstrap launcher before starting the user entrypoint. +- Support network modes `host`, `bridge`, and custom user-defined networks. +- Allocate host ports for `execd` and user service endpoints in non-host network modes. +- Restore expiration timers for existing managed containers after server restart. +- Support host bind mounts, Docker named volumes via the `pvc` volume model, and OSSFS-backed mounts. +- Attach an egress sidecar when `networkPolicy` is requested and Docker networking is compatible. +- Create Docker-backed persistent snapshots as local images and restore sandboxes from those snapshot images. + +Docker pause/resume uses container-level pause/resume. Docker snapshots are exposed through the public snapshot API. + +### 4.2 Kubernetes Runtime + +The Kubernetes runtime delegates actual workload creation to a workload provider selected by `kubernetes.workload_provider`. + +Supported providers: + +- `batchsandbox` - the default provider backed by OpenSandbox's Kubernetes controller and `BatchSandbox` CRD. +- `agent-sandbox` - a provider for `kubernetes-sigs/agent-sandbox`. + +The Kubernetes server path handles: + +- Kubernetes client initialization and optional informer-backed reads. +- Workload creation from image requests; `snapshotId` startup resolves to a stored restorable image when the snapshot record supports restore. +- Template merging for BatchSandbox and agent-sandbox manifests. +- Per-request image pull secrets where the provider supports them. +- Resource limits and GPU translation to Kubernetes extended resources. +- Platform constraints and RuntimeClass integration for secure runtimes. +- Volumes, egress sidecars, and secure endpoint access annotations. +- Endpoint resolution through direct workload data or ingress gateway configuration. +- Pause/resume delegation to providers. +- Plain-text diagnostics from Kubernetes resources. + +### 4.3 BatchSandbox Controller + +The Kubernetes controller under `kubernetes/` implements OpenSandbox-specific CRDs for high-throughput and pooled sandbox delivery: + +- `BatchSandbox`: create one or many sandbox replicas from a pod template. +- `Pool`: maintain pre-warmed resources for fast allocation. +- `SandboxSnapshot`: internal rootfs snapshot records used by Kubernetes pause/resume. + +BatchSandbox supports both template-based creation and pool-based creation via `extensions.poolRef`. It also supports optional task orchestration for batch and RL-style workloads. + +Kubernetes pause/resume is implemented through rootfs snapshots for `BatchSandbox.spec.replicas=1`: pause commits the sandbox root filesystem to an OCI image and releases runtime resources; resume rewrites the workload template to use the snapshot image and recreates the runtime while preserving the sandbox ID. + +The public snapshot API currently has a Docker-backed runtime implementation. Kubernetes pause/resume uses the controller's internal `SandboxSnapshot` flow; a general Kubernetes implementation of the public snapshot API is a separate runtime concern. + +## 5. Sandbox Data Plane + +Each sandbox runs the user's image and entrypoint, with OpenSandbox control processes injected around it. + +### 5.1 Execd + +`components/execd/` is a Go daemon built with Gin. It runs inside the sandbox and exposes the execution API. + +Responsibilities: + +- Shell command execution with SSE streaming. +- Background command status and incremental log retrieval. +- Persistent bash sessions. +- Interactive PTY sessions over WebSocket. +- File and directory operations. +- Jupyter-backed code contexts and code execution. +- Local CPU/memory metrics and optional OpenTelemetry metrics export. +- Optional shared access token enforcement through `X-EXECD-ACCESS-TOKEN`. + +In Docker, the server stages `execd` into the container and installs a bootstrap script. In Kubernetes BatchSandbox template mode, an init container copies `execd` and `bootstrap.sh` from the configured `execd_image` into an `emptyDir` volume mounted by the main sandbox container. + +### 5.2 Code Interpreter Runtime + +The code-interpreter sandbox image starts Jupyter inside the sandbox. `execd` talks to Jupyter over HTTP/WebSocket and translates Jupyter kernel messages into OpenSandbox streaming events. + +The Code Interpreter SDKs are optional high-level clients. The lower-level execution API remains available through the sandbox SDKs and direct `execd` clients. + +### 5.3 Volumes + +The lifecycle API exposes runtime-neutral volume models: + +- `host`: bind a permitted host path. +- `pvc`: platform-managed named storage. Docker maps this to a Docker named volume; Kubernetes maps it to a PersistentVolumeClaim. +- `ossfs`: mount Alibaba Cloud OSS through the server/runtime integration. + +Runtime providers validate and materialize these volume definitions differently, but the API shape stays shared. + +### 5.4 Egress Sidecar + +`components/egress/` enforces outbound network policy from the sandbox network namespace. + +Capabilities: + +- FQDN and wildcard-domain allow/deny rules. +- `dns` mode for DNS filtering. +- `dns+nft` mode for DNS plus nftables enforcement of resolved IPs and CIDR/IP rules where supported. +- Runtime policy inspection and patching through `/policy`. +- Optional sidecar authentication. +- Optional platform-enforced always-allow and always-deny overlays. +- Experimental transparent HTTPS MITM mode. + +Docker starts the egress sidecar as a separate container and runs the main sandbox container in the sidecar network namespace. Kubernetes appends the egress sidecar to the pod spec and drops `NET_ADMIN` from the main sandbox container so only the sidecar mutates network rules. + +## 6. Networking and Access + +### 6.1 Ingress + +`components/ingress/` is a Kubernetes-oriented HTTP/WebSocket reverse proxy. It watches sandbox resources and routes traffic to sandbox ports. + +Supported routing modes: + +- Header mode: `OpenSandbox-Ingress-To: -` or host parsing. +- URI mode: `///`. +- Wildcard host mode through server endpoint formatting. + +For `BatchSandbox`, ingress reads endpoint data from the `sandbox.opensandbox.io/endpoints` annotation. For `agent-sandbox`, it reads `status.serviceFQDN`. + +### 6.2 Secure Access + +`secureAccess` is currently supported for Kubernetes sandboxes exposed through ingress gateway mode. When enabled, the server provisions endpoint credentials and returns required headers with endpoint responses. Signed route tokens are also supported when gateway secure-access signing keys are configured. + +### 6.3 Auto-Renew on Access + +The optional renew-intent integration extends sandbox TTL when access is observed. It can be triggered by server proxy requests or by ingress gateway events delivered through Redis. Per-sandbox opt-in is controlled by the `extensions["access.renew.extend.seconds"]` create parameter. + +## 7. Core Flows + +### 7.1 Sandbox Creation + +```text +Client / SDK / CLI / MCP + -> POST /v1/sandboxes + -> FastAPI lifecycle server validates request and config + -> selected runtime service creates Docker container or Kubernetes workload + -> runtime stages execd and optional egress/volume/network configuration + -> sandbox reaches Running or reports Failed with status reason/message +``` + +Creation is asynchronous from the API perspective. Clients should poll `GET /v1/sandboxes/{sandboxId}` or use SDK readiness helpers. + +### 7.2 Command, File, and Code Execution + +```text +Client + -> resolve execd endpoint from sandbox metadata or server proxy + -> call execd API with X-EXECD-ACCESS-TOKEN when required + -> execd runs command, file operation, session, PTY, or Jupyter code execution + -> execd streams SSE/WebSocket output or returns structured responses +``` + +### 7.3 Service Exposure + +```text +Client + -> GET /v1/sandboxes/{sandboxId}/endpoints/{port} + -> server returns Docker-mapped, ingress-gateway, or server-proxy endpoint + -> client includes returned headers when secure access or sidecar auth requires them + -> HTTP/WebSocket traffic reaches the target sandbox port +``` + +### 7.4 Egress Policy + +```text +Create request with networkPolicy + -> server validates [egress] config + -> runtime attaches egress sidecar with initial policy + -> sandbox outbound DNS/network traffic is filtered by sidecar + -> clients may resolve the egress endpoint and PATCH /policy at runtime +``` + +### 7.5 Pause, Resume, and Snapshots + +```text +Pause / resume + -> lifecycle server delegates to runtime provider + -> Docker pauses/resumes the container + -> BatchSandbox uses rootfs snapshot commit/recreate for supported single-replica sandboxes + +Public snapshot API + -> server persists snapshot metadata + -> Docker runtime commits the sandbox to a restorable image + -> create-from-snapshot resolves that image and starts a new sandbox +``` + +## 8. Design Principles + +### Protocol First + +Public behavior starts from OpenAPI contracts in `specs/`. SDKs and clients should align to those contracts, and generated outputs should be regenerated from source specs rather than patched as the only fix. + +### Control Plane vs Data Plane + +The lifecycle server should orchestrate and validate. Platform-specific provisioning belongs in runtime services/providers. In-sandbox operations belong in `execd` and egress sidecars. + +### Runtime Neutral API, Runtime Specific Execution + +The lifecycle API uses shared concepts such as resource limits, volumes, endpoints, network policy, and metadata. Docker and Kubernetes can materialize those concepts differently while preserving the API contract. + +### Secure Defaults with Explicit Escape Hatches + +The server supports API-key authentication, startup guardrails for unauthenticated mode, resource limits, capability drops, optional secure runtimes, egress controls, endpoint headers, and platform-specific network isolation. Less restrictive modes are intended for local development or explicit operator choice. + +### Observable Failures + +Sandbox state includes `state`, `reason`, `message`, and transition time. `execd` exposes metrics, the server exposes diagnostics, ingress/egress/execd support logs and OpenTelemetry metrics where implemented, and request IDs are propagated for debugging. + +## 9. Common Use Cases + +- **Coding agents**: run Claude Code, Gemini CLI, Codex CLI, Qwen Code, Kimi CLI, or other agent tools in isolated sandboxes. +- **AI code execution**: execute model-generated code with command/file/code-interpreter APIs and streamed feedback. +- **Browser automation**: run Chrome or Playwright with controlled filesystem and network behavior. +- **Remote development**: expose VS Code Web, desktops, VNC, or development servers through sandbox endpoints. +- **RL and evaluation workloads**: use Kubernetes BatchSandbox, Pool, and task orchestration for high-throughput sandbox delivery. +- **Enterprise isolation**: combine secure runtimes, ingress, egress, endpoint access headers, and Kubernetes deployment controls. + +## 10. References + +- [Getting Started](/getting-started/) +- [Sandbox Lifecycle Spec](https://github.com/opensandbox-group/OpenSandbox/blob/main/specs/sandbox-lifecycle.yml) +- [Diagnostics Spec](https://github.com/opensandbox-group/OpenSandbox/blob/main/specs/diagnostic-api.yml) +- [Sandbox Execution Spec](https://github.com/opensandbox-group/OpenSandbox/blob/main/specs/execd-api.yaml) +- [Egress Spec](https://github.com/opensandbox-group/OpenSandbox/blob/main/specs/egress-api.yaml) +- [Server](/components/server) +- [Server Configuration](https://github.com/opensandbox-group/OpenSandbox/blob/main/server/configuration.md) +- [Execd](/components/execd) +- [Ingress](/components/ingress) +- [Egress](/components/egress) +- [Kubernetes](/kubernetes/) +- [Pause and Resume](/guides/pause-resume) +- [Secure Container Runtime Guide](/guides/secure-container) +- [Network Isolation](/architecture/network-isolation) +- [CLI](/cli/) +- [MCP Server](/sdks/mcp) +- [Examples](/examples/) diff --git a/docs/architecture/network-isolation.md b/docs/architecture/network-isolation.md new file mode 100644 index 0000000..cb331da --- /dev/null +++ b/docs/architecture/network-isolation.md @@ -0,0 +1,189 @@ +--- +title: Network Isolation +description: Why Kubernetes NetworkPolicy is insufficient for sandbox isolation and how OpenSandbox uses egress sidecar controls instead. +--- + +# Network Isolation on Kubernetes + +## Problem + +On a Kubernetes cluster, each OpenSandbox sandbox runs as an independent Pod with a dedicated Pod IP assigned by the CNI plugin. By default, any sandbox can reach other sandboxes in the same cluster directly via Pod IP. This introduces several security risks: + +- **Internal network scanning**: malicious code inside a sandbox can scan the cluster Pod CIDR range to discover other sandboxes. +- **Unauthorized access**: an attacker can connect to other sandboxes' listening ports directly, bypassing OpenSandbox's authentication and authorization mechanisms. +- **Data leakage**: when sandboxes from different tenants co-exist in the same cluster, the lack of isolation may lead to unauthorized cross-tenant data access. + +A mechanism is needed to prevent direct IP-based communication between sandboxes while preserving legitimate external access paths (via OpenSandbox Ingress). + +## Native Kubernetes NetworkPolicy + +Kubernetes NetworkPolicy controls traffic through **Pod label selectors**. It faces fundamental limitations in the sandbox isolation scenario: + +- **Unpredictable labels**: sandbox Pod labels are injected automatically by the platform and are not user-controllable. Sandboxes from different tenants or security levels may share the same label set, making it impossible to define precise isolation boundaries with label selectors. +- **Dynamic lifecycle**: sandboxes are created and destroyed frequently. NetworkPolicy is a static declaration and cannot track the expected isolation relationships of each sandbox in real time. +- **Granularity mismatch**: NetworkPolicy operates on Pod sets (namespace + label selector), while sandbox isolation requires each sandbox to be an independent security domain that denies access from all other sandboxes by default. Expressing "deny access from all other Pods to me" with NetworkPolicy would require a separate rule per sandbox, and cannot cover sandboxes created in the future. +- **No outbound control**: NetworkPolicy Ingress rules can block inbound traffic, but cannot prevent sandbox processes from initiating outbound connections (e.g., curling another sandbox's Pod IP). Blocking outbound direct connections requires bidirectional (Ingress + Egress) policies per sandbox, which runs into the same label unpredictability and dynamicity problems. + +Therefore, OpenSandbox does not rely on native Kubernetes NetworkPolicy. Instead, it uses **sandbox-level egress control** to enforce sandbox-to-sandbox isolation. The control point lives inside the sandbox (egress sidecar), not at the cluster network layer. + +## Approach 1: Global Enforced Isolation via Egress Sidecar `deny.always` + +### How It Works + +The OpenSandbox egress sidecar provides a `deny.always` mechanism: place a file at `/var/egress/rules/deny.always` inside the sidecar container, and the rules declared in it take effect **unconditionally**, with higher priority than any allow rules set by users via the SDK or API. + +The implementation is in `components/egress/pkg/policy/always_rules.go`: + +```go +func MergeAlwaysOverlay(user *NetworkPolicy, alwaysDeny, alwaysAllow []EgressRule) *NetworkPolicy { + // alwaysDeny is prepended so it matches first, achieving unconditional denial + merged = append(merged, alwaysDeny...) + merged = append(merged, alwaysAllow...) + merged = append(merged, out.Egress...) +} +``` + +`MergeAlwaysOverlay` is called on every policy change (both initial startup and runtime updates), merging always-deny, always-allow, and user policies by priority. Since rules match in order, `deny.always` rules have the highest priority and cannot be overridden by users through the SDK or API. + +Additionally, the `deny.always` file is automatically reloaded every minute, so updates take effect without restarting the container. + +### Configuration Steps + +#### 1. Determine Cluster Pod CIDR and Service CIDR + +Obtain the Pod and Service CIDR ranges of the Kubernetes cluster: + +```bash +# Service CIDR: usually set by kube-controller-manager's --service-cluster-ip-range +# Pod CIDR: usually set by the CNI plugin or kube-controller-manager's --pod-network-cidr +``` + +Common example values: +- Pod CIDR: `10.244.0.0/16` +- Service CIDR: `10.96.0.0/12` + +#### 2. Create the `deny.always` Rule File + +```text +10.244.0.0/16 +10.96.0.0/12 +``` + +Supported target types: IP addresses (e.g., `10.0.0.5`), CIDR ranges (e.g., `10.0.0.0/8`), or domain names (e.g., `internal.service.local`, with `*.` wildcard prefix support). + +#### 3. Build a Custom Image Based on the Official Egress Image + +Create a `Dockerfile` that embeds the `deny.always` file into the image: + +```dockerfile +FROM opensandbox/egress:latest + +COPY deny.always /var/egress/rules/deny.always +``` + +Build and push to a registry accessible by the cluster: + +```bash +docker build -t registry.example.com/opensandbox/egress:hardened . +docker push registry.example.com/opensandbox/egress:hardened +``` + +#### 4. Update the Server Configuration + +In the OpenSandbox server configuration, point the `[egress]` `image` to the custom image: + +```toml +[egress] +image = "registry.example.com/opensandbox/egress:hardened" +mode = "dns+nft" +``` + +After this configuration is applied, all newly created sandboxes will use the egress sidecar image that includes the `deny.always` rules. No changes to individual sandbox creation requests are needed. + +> Note: the `deny.always` file is hot-reloaded every minute. To change the rules (e.g., after a cluster CIDR change), update the `deny.always` file, rebuild the image, and perform a rolling update of the server configuration. + +### Effects + +- **Pod IP blocked**: sandboxes cannot directly reach other Pods in the same cluster via Pod IP. +- **Service ClusterIP blocked**: sandboxes cannot reach in-cluster services via Service ClusterIP. +- **Forced access path**: legitimate cross-sandbox communication must go through the `GetEndpoint()` API, which returns an external access endpoint proxied by OpenSandbox Ingress with authentication and authorization. +- **Transparent to users**: users do not need to declare any additional parameters in SDK calls. Isolation is enforced automatically at the platform level. + +## Allowing Legitimate In-Cluster Services + +When `defaultAction: deny` is enabled, allowing a sandbox to reach a Kubernetes Service requires **both** layers of the egress sidecar to agree: + +1. **DNS layer**: allow the service hostname so the DNS proxy does not return `NXDOMAIN`. +2. **Network layer** (`dns+nft` mode): allow the Service CIDR (or a narrower ClusterIP range) so nftables does not drop the resolved TCP connection. + +For example, if a sandbox should reach `postgres.opensandbox.svc.cluster.local`, this is not enough by itself: + +```json +{ "action": "allow", "target": "postgres.opensandbox.svc.cluster.local" } +``` + +The request can still fail after DNS resolution if the Service ClusterIP falls inside a denied CIDR such as `10.96.0.0/12`. In `dns+nft` mode you must also allow the Service CIDR (or the specific ClusterIP range you want exposed): + +```json +{ "action": "allow", "target": "10.96.0.0/12" } +``` + +### Recommended Patterns + +- **Platform-managed allowlist**: if many sandboxes need the same internal services, keep the Service CIDR allowance in `allow.always` and let users request only the DNS names they should reach. +- **Per-sandbox allowlist**: if access should be tightly scoped, include both the service FQDN and the required Service CIDR in the sandbox's `network_policy` / `networkPolicy`. +- **Avoid assuming CoreDNS exemptions are enough**: the sidecar automatically allows nameserver IPs so DNS forwarding works, but it does **not** automatically allow arbitrary Service ClusterIPs for application traffic. + +## Approach 2: On-Demand Isolation via Per-Sandbox NetworkPolicy + +If global enforced isolation is not desired, or if specific sandboxes need more permissive access, you can explicitly deny internal network access at sandbox creation time via the `network_policy` parameter: + +```python +from opensandbox import Sandbox, NetworkPolicy, EgressRule + +sandbox = await Sandbox.create( + image="python:3.11", + network_policy=NetworkPolicy( + default_action="deny", + egress=[ + EgressRule(action="deny", target="10.244.0.0/16"), # Deny Pod CIDR + EgressRule(action="deny", target="10.96.0.0/12"), # Deny Service CIDR + EgressRule(action="allow", target="*.example.com"), # Allow external domains + ], + ), +) +``` + +### Limitations + +- **Per-sandbox declaration**: each sandbox must explicitly pass the `network_policy` parameter at creation. The platform cannot enforce it by default. +- **CIDR exposure**: users need to know the cluster's Pod CIDR and Service CIDR. In multi-tenant scenarios, this is sensitive information that should not be exposed to users. +- **Overridable**: users can declare higher-priority allow rules within the same `network_policy` to override their own deny rules, making this less secure than the `deny.always` enforcement in Approach 1. + +## Comparison + +| Dimension | Global `deny.always` | Per-Sandbox NetworkPolicy | +|-----------|---------------------|---------------------------| +| Enforcement | Yes (user cannot override) | No (user can modify policy) | +| User awareness | Transparent (platform-level) | Requires explicit declaration | +| Operational cost | Low (one-time image build, global effect) | High (declare per sandbox) | +| Cluster CIDR exposure | Not exposed to users | Must be exposed to users | +| Use case | Platform-wide default isolation, recommended | Whitelist mode, fine-grained control | + +## Runtime Compatibility + +Both Approach 1 (`deny.always` via egress sidecar) and Approach 2 (per-sandbox `network_policy`) depend on the egress sidecar, which uses an iptables `nat` table REDIRECT rule for DNS interception. This works with `runc` (default) and all Kata Containers variants (`kata-qemu`, `kata-clh`, `kata-fc`), but **not with gVisor** — gVisor's netstack does not implement the `nat` table. + +If you need both gVisor's syscall isolation and FQDN egress control: +- Use `kata-qemu` instead — it provides comparable security isolation and supports the egress sidecar. +- Alternatively, use a CNI-level FQDN policy (e.g., Cilium `toFQDNs`) for network isolation alongside gVisor. + +The same architectural constraint applies to transparent service meshes such as Istio/Envoy sidecar injection: OpenSandbox egress expects to own outbound interception inside the pod network namespace. If a mesh sidecar also rewrites outbound traffic in that namespace, egress-sidecar features such as per-sandbox network policy, transparent MITM, and Credential Vault are not currently supported together. Prefer excluding sandbox pods from mesh injection or enforcing outbound policy at the platform network layer instead. + +See the [Compatibility Matrix](../guides/secure-container.md#compatibility-matrix) in the Secure Container Runtime Guide for the full feature support table. + +## Recommendations + +1. **Default full isolation**: use `deny.always` to block the cluster's internal CIDR ranges as the platform's default security baseline. +2. **Whitelist with `allow.always`**: for scenarios requiring cross-sandbox communication (e.g., an agent dispatching tasks to sandboxes), use the `allow.always` file (`/var/egress/rules/allow.always`) to open specific Pod IPs or Service DNS names. `allow.always` has higher priority than user policies but lower than `deny.always`, giving the platform precise control over the open scope. +3. **External access entry**: the only way for a sandbox to expose services externally should be the `GetEndpoint()` API, proxied through OpenSandbox Ingress. Pod IPs should not be used as external service endpoints. diff --git a/docs/architecture/single-host-network.md b/docs/architecture/single-host-network.md new file mode 100644 index 0000000..d608bbd --- /dev/null +++ b/docs/architecture/single-host-network.md @@ -0,0 +1,37 @@ +--- +title: Single-Host Network +description: How execd's reverse proxy gives every sandbox access to all HTTP/WebSocket ports through a single exposed host port. +--- + +# Single-Host Network + +Detailed routing for a single-host deployment: how execd’s proxy gives every sandbox access to HTTP and WebSocket ports through one exposed host port. + +![Single-host sandbox routing](../public/images/single_host_network.png) + +## Single-host routing model +- Every sandbox container starts `execd` listening on container port `44772`. `execd` bundles a lightweight reverse proxy that intercepts requests with the `/proxy/{port}` prefix and forwards them to `127.0.0.1:{port}` inside the same container. +- The Docker runtime binds only the host side of the execd proxy port (labeled `opensandbox.io/embedding-proxy-port`). Callers use `get_endpoint(..., port=X)` to receive `{public_host}:{host_proxy_port}/proxy/{X}`, and execd transparently routes the request back to the sandbox service on port `X`. +- Because the proxy preserves `Upgrade`, `Connection`, and other HTTP headers, HTTP, Server-Sent Events, and WebSocket traffic share the same mapped host port without additional configuration. +- With this setup, a single host port per sandbox suffices to reach **all** container ports. You can safely run many sandboxes on one machine without worrying about overlapping host port allocations. +- When the caller lives inside the same Docker network (e.g., another container or Kubernetes pod), use `get_endpoint(..., resolve_internal=True)` to bypass the host mapping and return the sandbox IP (e.g., `172.17.0.3:5900`) instead. +- The diagram above shows the routing path: host traffic hits the proxy port, execd rewrites the request towards the target container port, and upstream services remain isolated within the sandbox. + +## Network modes + +### Host network mode (single-host constraints) +- Containers share the host network stack (`network_mode=host`) so sandbox ports are directly accessible on the host. +- Because each sandbox binds its ports on the host, this mode practically limits you to one sandbox instance per host unless you reserve dedicated ports per sandbox. +- `get_endpoint(..., port=X)` returns `{public_host}:{X}` with no `/proxy/` prefix, so the caller needs to know the exact host port and the host must manage firewall rules for each sandbox port. + +### Bridge network mode (default for single-host deployments) +- Docker places sandboxes on an isolated bridge network, preventing container ports from being reachable without explicit mapping. +- For single-host scaling, OpenSandbox maps only execd’s proxy port (`44772`) and, optionally, port `8080`. Any other container port stays private and is reached via the proxy. +- The reverse proxy label (`opensandbox.io/embedding-proxy-port`) identifies a host port that fronts `execd`. `get_endpoint(..., port=X)` returns `{public_host}:{host_proxy_port}/proxy/{X}`, so all internal ports can share the same host binding. +- Port `8080` may also receive a direct host binding (`opensandbox.io/http-port`), providing a conventional HTTP endpoint without the proxy path when required. +- This bridge setup lets a single machine host many sandboxes without port conflicts, because the same host proxy port can multiplex requests for HTTP, SSE, WebSocket, VNC, etc. + +## Operational notes +- If execd’s proxy port (`44772`) or the optional `8080` host mapping is missing, `get_endpoint` responds with HTTP 500 and a message stating which mapping was unavailable. +- Always keep the `/proxy/{port}` prefix (including any additional path or query string) when embedding URLs in browser-based clients or SDKs so that execd can correctly dispatch the request. +- This proxy-based approach means additional ports never need to be published on the host, simplifying firewall management and improving security. diff --git a/docs/cli/index.md b/docs/cli/index.md new file mode 100644 index 0000000..b9331e0 --- /dev/null +++ b/docs/cli/index.md @@ -0,0 +1,413 @@ +--- +title: CLI +description: The osb command-line interface for managing sandboxes, executing commands, handling files, and installing agent skills. +--- + +# OpenSandbox CLI + +`osb` is the command-line interface for OpenSandbox. It is built for the common day-to-day flows: + +- create and manage sandboxes +- run commands inside a sandbox +- read and modify sandbox files +- inspect runtime egress policy +- manage sandbox-local Credential Vault state +- collect low-level diagnostics +- install OpenSandbox-specific skills for coding agents + +It uses the OpenSandbox Python SDK under the hood and is intended to be the shortest path from a terminal to a working sandbox workflow. + +## Install + +Choose one: + +```bash +pip install opensandbox-cli +``` + +```bash +uv tool install opensandbox-cli +``` + +```bash +pipx install opensandbox-cli +``` + +Confirm the install: + +```bash +osb --help +osb --version +``` + +## Before You Start + +Make sure an OpenSandbox server is reachable. If you are running locally, start the server first and then point the CLI at it. + +```bash +opensandbox-server +``` + +## Quick Start + +### 1. Initialize config + +```bash +osb config init +osb config set connection.domain localhost:8080 +osb config set connection.protocol http +osb config set connection.api_key +osb config show -o json +``` + +If you want a non-default config file, choose it at the root command level for the whole invocation: + +```bash +osb --config /tmp/dev.toml config init +osb --config /tmp/dev.toml config set connection.domain localhost:8080 +osb --config /tmp/dev.toml config show -o json +``` + +### 2. Create a sandbox + +```bash +osb sandbox create --image python:3.12 --timeout 30m -o json +``` + +If you set defaults first, later create commands can be shorter: + +```bash +osb config set defaults.image python:3.12 +osb config set defaults.timeout 30m +osb sandbox create -o json +``` + +### 3. Verify it is usable + +```bash +osb sandbox get -o json +osb sandbox health -o json +``` + +### 4. Run a command inside the sandbox + +Use `--` before the sandbox command payload. + +```bash +osb command run -o raw -- python -c "print(1 + 1)" +``` + +### 5. Read or write a file + +```bash +osb file write /workspace/hello.txt -c "hello" -o json +osb file cat /workspace/hello.txt -o raw +``` + +### 6. Clean up + +```bash +osb sandbox kill -o json +``` + +## Common Tasks + +### Create sandboxes + +Basic: + +```bash +osb sandbox create --image python:3.12 +``` + +Private image: + +```bash +osb sandbox create \ + --image my-registry.example.com/team/app:latest \ + --image-auth-username alice \ + --image-auth-password +``` + +Manual cleanup mode: + +```bash +osb sandbox create --image python:3.12 --timeout none +``` + +Explicit entrypoint argv: + +```bash +osb sandbox create \ + --image python:3.12 \ + --entrypoint python \ + --entrypoint -m \ + --entrypoint http.server +``` + +Create with network policy and volumes: + +```bash +osb sandbox create \ + --image python:3.12 \ + --network-policy-file network-policy.json \ + --volumes-file volumes.json +``` + +Create with Credential Vault proxy enabled: + +```bash +osb sandbox create --image python:3.12 --network-policy-file network-policy.json --credential-proxy -o json +``` + +### List and inspect sandboxes + +```bash +osb sandbox list +osb sandbox list -o json +osb sandbox list --state running --state paused +osb sandbox get -o json +osb sandbox metrics +osb sandbox metrics --watch -o raw +``` + +### Expose a service + +```bash +osb sandbox endpoint --port 8080 -o json +``` + +### Run commands + +Foreground streaming: + +```bash +osb command run -o raw -- sh -lc 'echo ready' +``` + +Tracked background execution: + +```bash +osb command run --background -o json -- sh -c "sleep 10; echo done" +osb command status -o json +osb command logs -o json +``` + +Persistent shell session: + +```bash +osb command session create --workdir /workspace -o json +osb command session run -o raw -- pwd +osb command session run -o raw -- export FOO=bar +osb command session run -o raw -- sh -c 'echo $FOO' +osb command session delete -o json +``` + +### Work with files + +```bash +osb file upload ./local.txt /workspace/local.txt -o json +osb file download /workspace/result.json ./result.json -o json +osb file search /workspace --pattern "*.py" -o json +osb file info /workspace/main.py -o json +osb file replace /workspace/app.py --old old --new new -o json +osb file chmod /workspace/script.sh --mode 755 -o json +``` + +### Manage runtime egress policy + +Inspect current policy: + +```bash +osb egress get -o json +``` + +Patch specific rules: + +```bash +osb egress patch --rule allow=pypi.org --rule deny=internal.example.com -o json +``` + +If you are debugging connectivity, verify behavior with an actual command: + +```bash +osb command run -o raw -- curl -I https://pypi.org +``` + +### Manage Credential Vault + +Credential Vault operations call the sandbox egress sidecar through the Python SDK. +Create the sandbox with `--credential-proxy` and an explicit network policy before +writing vault state. + +```bash +osb credential-vault create --file vault.yaml -o json +osb credential-vault get -o json +osb credential-vault patch --file mutation.yaml -o json +osb credential-vault credential list -o json +osb credential-vault binding list -o json +osb credential-vault delete -o json +``` + +::: warning +Use `--file -` to read a JSON/YAML payload from stdin. Do not pass plaintext credential values as command-line flags; keep them in the payload stream or file. +::: + +### Collect diagnostics + +Use the stable diagnostics commands for API-backed log and event descriptors. + +```bash +osb diagnostics events --scope lifecycle -o raw +osb diagnostics events --scope runtime -o raw +osb diagnostics logs --scope container -o raw +osb diagnostics logs --scope lifecycle -o json +osb diagnostics events --scope runtime -o json +osb diagnostics logs --scope container -o yaml +``` + +`--scope` is required for stable diagnostics. Common scopes are `lifecycle` and +`container` for logs, and `lifecycle` and `runtime` for events. Raw output +prints inline diagnostic text, or the content URL when diagnostics are +delivered as a temporary URL. + +::: info +Legacy DevOps diagnostics remain experimental. Prefer `osb diagnostics logs/events` for stable API-backed log and event collection. +::: + +```bash +osb devops inspect -o raw +osb devops summary -o raw +``` + +## Output Formats + +Output selection is command-scoped, not global. + +- `table`: human-readable tables and panels +- `json`: machine-readable JSON +- `yaml`: machine-readable YAML +- `raw`: unformatted text or streaming output + +Examples: + +```bash +osb sandbox list -o json +osb sandbox list -o yaml +osb file cat /workspace/hello.txt -o raw +``` + +Not every command supports every format. Use `--help` on the specific command when in doubt. + +## Command Groups + +The main command groups are: + +- `osb sandbox`: lifecycle management +- `osb command`: command execution and persistent sessions +- `osb file`: file and directory operations +- `osb egress`: runtime egress policy +- `osb diagnostics`: stable diagnostics logs and events +- `osb devops`: experimental legacy diagnostics +- `osb config`: local CLI configuration +- `osb skills`: bundled skills for AI tools + +Explore them directly: + +```bash +osb sandbox --help +osb command --help +osb file --help +osb skills --help +``` + +## Agent Skills + +The CLI ships with built-in OpenSandbox skills for coding agents and agent-oriented tools. + +Bundled skills: + +- `sandbox-lifecycle` +- `command-execution` +- `file-operations` +- `network-egress` +- `sandbox-troubleshooting` + +Supported targets: + +| Target | Install location | +| --- | --- | +| `claude` | `./.claude/skills/` or `~/.claude/skills/` | +| `cursor` | `./.cursor/rules/` or `~/.cursor/rules/` | +| `codex` | `./.codex/skills//SKILL.md` or `~/.codex/skills//SKILL.md` | +| `copilot` | `./.github/copilot-instructions.md` or `~/.github/copilot-instructions.md` | +| `windsurf` | `./.windsurfrules` or `~/.windsurfrules` | +| `cline` | `./.clinerules` or `~/.clinerules` | +| `opencode` | `./.agents/skills//SKILL.md` or `~/.agents/skills//SKILL.md` | + +Common flows: + +```bash +osb skills list +osb skills show sandbox-lifecycle +osb skills install sandbox-lifecycle --target codex --scope project +osb skills install --all-builtins --target codex --scope global +osb skills uninstall sandbox-troubleshooting --target claude --scope global +``` + +For scripts or agents, use structured output: + +```bash +osb skills install sandbox-lifecycle --target codex --scope project -o json +``` + +## Configuration Model + +The CLI resolves configuration in this order: + +1. root CLI flags such as `--api-key`, `--domain`, `--protocol`, `--request-timeout`, `--config` +2. environment variables such as `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN` +3. config file, defaulting to `~/.opensandbox/config.toml` +4. SDK defaults + +Config commands: + +```bash +osb config init +osb config show +osb config set connection.domain localhost:8080 +osb config set connection.protocol http +osb config set defaults.image python:3.12 +osb config set defaults.timeout 30m +``` + +Example config file: + +```toml +[connection] +api_key = "your-api-key" +domain = "localhost:8080" +protocol = "http" +request_timeout = 30 +use_server_proxy = false + +[output] +color = true + +[defaults] +image = "python:3.12" +timeout = "30m" +``` + +## Development + +For local development in this monorepo: + +```bash +cd cli +uv sync +uv run osb --help +uv run pytest +``` + +This repository uses a local `uv` source override for the OpenSandbox Python SDK, so running from `cli/` will resolve against the checked-out SDK in the monorepo. diff --git a/docs/community/code-of-conduct.md b/docs/community/code-of-conduct.md new file mode 100644 index 0000000..b7300ae --- /dev/null +++ b/docs/community/code-of-conduct.md @@ -0,0 +1,12 @@ +--- +title: Code of Conduct +description: OpenSandbox community code of conduct. +--- + +# Code of Conduct + +The code of conduct is maintained in the repository root as the single source of truth. + + + Read the Code of Conduct on GitHub → + diff --git a/docs/community/contributing.md b/docs/community/contributing.md new file mode 100644 index 0000000..828805c --- /dev/null +++ b/docs/community/contributing.md @@ -0,0 +1,12 @@ +--- +title: Contributing +description: Guide for contributing to the OpenSandbox project. +--- + +# Contributing to OpenSandbox + +The contributing guide is maintained in the repository root to keep GitHub's contributor experience consistent. + + + Read the Contributing Guide on GitHub → + diff --git a/docs/community/oseps.md b/docs/community/oseps.md new file mode 100644 index 0000000..1460a7d --- /dev/null +++ b/docs/community/oseps.md @@ -0,0 +1,54 @@ +--- +title: Enhancement Proposals (OSEPs) +description: Index of OpenSandbox Enhancement Proposals covering major features, architectural changes, and API modifications. +--- + +# OpenSandbox Enhancement Proposals + +OpenSandbox Enhancement Proposals (OSEPs) are the mechanism for proposing major features, architectural changes, or modifications to the core API and security model. Small bug fixes and minor improvements do not require an OSEP. + +See the [OSEP contributing guide](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/CONTRIBUTING.md) for information on how to create and merge proposals. + +## Proposal Index + +| OSEP | Title | Status | Last Updated | +|:----:|:-----:|:------:|:------------:| +| [OSEP-0001](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0001-fqdn-based-egress-control.md) | FQDN-based Egress Control | implemented | 2026-01-22 | +| [OSEP-0002](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0002-kubernetes-sigs-agent-sandbox-support.md) | kubernetes-sigs/agent-sandbox Support | implemented | 2026-01-23 | +| [OSEP-0003](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0003-volume-and-volumebinding-support.md) | Volume Support | implementing | 2026-02-11 | +| [OSEP-0004](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0004-secure-container-runtime.md) | Pluggable Secure Container Runtime Support | implemented | 2026-02-09 | +| [OSEP-0005](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0005-client-side-sandbox-pool.md) | Client-Side Sandbox Pool | implementing | 2026-03-09 | +| [OSEP-0006](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0006-developer-console.md) | Developer Console for Sandbox Operations | implementable | 2026-03-06 | +| [OSEP-0007](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0007-fast-sandbox-runtime-support.md) | Fast Sandbox Runtime Support | provisional | 2026-02-08 | +| [OSEP-0008](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0008-pause-resume-rootfs-snapshot.md) | Pause and Resume via Rootfs Snapshot | implementing | 2026-03-13 | +| [OSEP-0009](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0009-auto-renew-sandbox-on-ingress-access.md) | Auto-Renew Sandbox on Ingress Access | implemented | 2026-03-23 | +| [OSEP-0010](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0010-opentelemetry-instrumentation.md) | OpenTelemetry Metrics and Logs (execd, egress, and ingress) | implementing | 2026-04-12 | +| [OSEP-0011](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0011-secure-access-endpoint.md) | Secure Access on GetEndpoint and Signed Endpoint | implemented | 2026-04-25 | +| [OSEP-0012](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0012-credential-vault.md) | Credential Vault and Credential Proxy | implemented | 2026-06-23 | +| [OSEP-0013](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0013-isolated-execution-api.md) | Isolated Execution API | implementing | 2026-06-23 | +| [OSEP-0014](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0014-multi-tenancy.md) | Multi-Tenancy Support for Kubernetes Runtime | draft | 2026-04-29 | + +## Status Definitions + +| Status | Meaning | +|--------|---------| +| **draft** | Proposal is under initial development, not yet accepted | +| **provisional** | Proposal accepted in principle, design may still evolve | +| **implementable** | Design is final and ready for implementation | +| **implementing** | Implementation is in progress | +| **implemented** | Fully implemented and available | + +## Submitting a Proposal + +::: tip +Before writing a full OSEP, consider opening a GitHub Issue or Discussion to gauge community interest and get early feedback on your idea. +::: + +To submit a new OSEP: + +1. Fork the repository +2. Copy the OSEP template +3. Fill in the proposal details +4. Submit a pull request + +See the [OSEP contributing guide](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/CONTRIBUTING.md) for the full process. diff --git a/docs/community/release-automation.md b/docs/community/release-automation.md new file mode 100644 index 0000000..3fcd486 --- /dev/null +++ b/docs/community/release-automation.md @@ -0,0 +1,259 @@ +--- +title: Release Automation +description: Tag-driven release workflow for OpenSandbox SDKs, CLI, server, Docker images, and Helm charts. +--- + +# Release Automation + +This repository uses tag-driven publish workflows. The script below standardizes: + +- canonical tag creation for each release target +- release note generation from previous release to current commit +- GitHub Release create/update +- signed source archive upload and provenance attestation in the Generic + Release workflow + +Script path: + +- `scripts/release/create-release.sh` + +## Supported Targets + +- `js/sandbox` +- `js/code-interpreter` +- `python/sandbox` +- `python/code-interpreter` +- `python/mcp/sandbox` +- `java/sandbox` +- `java/code-interpreter` +- `csharp/sandbox` +- `csharp/code-interpreter` +- `sdks/sandbox/go` +- `cli` +- `server` +- `docker/execd` +- `docker/code-interpreter` +- `docker/ingress` +- `docker/egress` +- `k8s/controller` +- `k8s/task-executor` +- `helm/opensandbox` +- `helm` (alias of `helm/opensandbox`) + +## Tag Rules + +The script aligns with existing workflow triggers: + +- v-prefixed tags: + - `/v` for SDK/CLI/Server targets + - examples: `js/sandbox/v1.0.5`, `server/v0.2.0` + - Go SDK example: `sdks/sandbox/go/v1.0.0` +- plain suffix tags: + - `/` for docker/k8s/helm targets + - examples: `docker/execd/v0.3.0`, `helm/opensandbox/0.1.0` + +Release tag namespaces are protected by repository rulesets. Only authorized +release managers may create matching tags, and an existing release tag cannot +be updated or deleted. + +## Release Approval and Source Verification + +Hosted publish workflows run a shared release preflight before publishing: + +- the release commit must be reachable from `origin/main` +- non-dry-run releases require approval through the `release` environment +- the person who triggered the release cannot approve their own deployment + +This implements two-person control: one person initiates the release and a +different Project Maintainer approves it. GitHub environments require one of +the configured reviewers; they do not natively support requiring two reviewer +approvals in addition to the initiator. + +The hosted Generic Release workflow does not create or push release tags. An +authorized release manager must create the tag from a commit on `main` before +running a non-dry-run Generic Release. + +## Release Notes Format + +Generated notes follow this section structure: + +- `## What's New` +- `### ✨ Features` +- `### 🐛 Bug Fixes` +- `### ⚠️ Breaking Changes` +- `### 📦 Misc` +- `## 👥 Contributors` + +Commit categorization: + +- `feat:` -> Features +- `fix:` -> Bug Fixes +- `BREAKING CHANGE` or `type!:` -> Breaking Changes +- everything else -> Misc + +## Usage + +```bash +scripts/release/create-release.sh --target --version [options] +``` + +Required: + +- `--target` +- `--version` + +Options: + +- `--from-tag `: explicit previous release boundary +- `--path `: append custom path filter (repeatable) +- `--no-path-filter`: disable default target path scope and use whole range +- `--initial-release`: allow no previous tag; use full history +- `--dry-run`: render computed tag/range/notes without side effects +- `--push`: push created tag to origin +- `--sign-tag`: create a cryptographically signed git tag using the local git + signing configuration. This is intended for local release-operator use, not + the hosted GitHub Actions release workflow. + +## Path Filtering Strategy + +By default, each target only includes commits from target-related paths to reduce noise. + +Examples: + +- `js/sandbox` -> `sdks/sandbox/javascript` + `specs/sandbox-lifecycle.yml` +- `server` -> `server` + `specs/sandbox-lifecycle.yml` +- `docker/egress` -> `components/egress` +- `helm/opensandbox` -> `kubernetes/charts/opensandbox` + +Override behavior: + +- Add extra scope with `--path`: + - `--path docs/` or `--path specs/execd-api.yaml` +- Disable default scope with `--no-path-filter`: + - falls back to the entire commit range (`from..HEAD`) + +## Common Examples + +Dry-run JavaScript SDK release: + +```bash +scripts/release/create-release.sh --target js/sandbox --version 1.0.5 --dry-run +``` + +Dry-run server release: + +```bash +scripts/release/create-release.sh --target server --version 0.2.0 --dry-run +``` + +Dry-run JavaScript SDK release with additional docs scope: + +```bash +scripts/release/create-release.sh --target js/sandbox --version 1.0.5 --dry-run --path docs/ +``` + +Dry-run JavaScript SDK release without path filtering (full range): + +```bash +scripts/release/create-release.sh --target js/sandbox --version 1.0.5 --dry-run --no-path-filter +``` + +Server release with tag push: + +```bash +scripts/release/create-release.sh --target server --version 0.2.0 --push +``` + +Component image release: + +```bash +scripts/release/create-release.sh --target docker/execd --version v0.3.0 --push +``` + +Helm chart release: + +```bash +scripts/release/create-release.sh --target helm/opensandbox --version 0.1.0 --push +``` + +## Dry-Run Output Example + +Example output format for `--dry-run`: + +```text +[release] Target: js/sandbox +[release] Workflow: .github/workflows/publish-js-sdks.yml +[release] New tag: js/sandbox/v1.0.5 +[release] Previous tag: js/sandbox/v0.1.4 +[release] Path filters: sdks/sandbox/javascript specs/sandbox-lifecycle.yml +[release] Dry run enabled. No tag/release side effects will be performed. +[release] Computed range: js/sandbox/v0.1.4..HEAD + +[release] Generated release notes preview: +------------------------------------------------------------ +# JavaScript Sandbox SDK v1.0.5 +## What's New +Changes included since `js/sandbox/v0.1.4`. +Scoped paths: `sdks/sandbox/javascript specs/sandbox-lifecycle.yml`. + +### ✨ Features +- feat(sdks/js): support run_in_session + +### 🐛 Bug Fixes +- fix(lifecycle): harden sdk compatibility and e2e stability + +### ⚠️ Breaking Changes +- None + +### 📦 Misc +- chore(sdks): rebuild source code +------------------------------------------------------------ +``` + +If `--dry-run` is enabled, the script never creates/pushes tags and never creates/updates GitHub Releases. + +## Safety Defaults + +- The script creates/updates GitHub Release only when not in `--dry-run`. +- Tag push is opt-in (`--push`), preventing accidental workflow trigger. +- Tag signing is opt-in (`--sign-tag`) because it requires release-operator git + signing keys. The hosted GitHub Actions release workflow does not expose this + option. Official release artifacts are still signed by the GitHub release + workflows through Sigstore/GitHub attestations. +- If previous tag cannot be found, script fails unless `--from-tag` or `--initial-release` is provided. + +## GitHub Actions Entry + +You can trigger the same flow in GitHub Actions from: + +- `.github/workflows/release-generic.yml` + +Inputs exposed in the workflow dispatch form: + +- `target` +- `version` +- `from_tag` (optional) +- `initial_release` (boolean) +- `dry_run` (boolean, default `true`) + +Dry-run in GitHub Actions: + +- set `dry_run=true` +- check logs for: + - computed tag (`New tag`) + - range (`Computed range`) + - preview body (`Generated release notes preview`) + +Recommended first run in UI: + +- set `dry_run=true` +- verify the generated release notes preview in logs +- have an authorized release manager create and push the release tag from + `main` +- select that tag as the workflow ref and rerun with `dry_run=false` + +When `dry_run=false`, `.github/workflows/release-generic.yml` uploads an +explicit `opensandbox-.tar.gz` source archive and `SHA256SUMS` file to the +GitHub Release, then signs both files with GitHub/Sigstore provenance +attestations. See [Release Verification](release-verification.md) for user +verification commands and release signing coverage. diff --git a/docs/community/release-verification.md b/docs/community/release-verification.md new file mode 100644 index 0000000..3ba5c61 --- /dev/null +++ b/docs/community/release-verification.md @@ -0,0 +1,243 @@ +--- +title: Release Verification +description: Verify signed OpenSandbox releases for source archives, container images, packages, and Helm charts. +--- + +# Release Verification + +OpenSandbox signs public release outputs without changing the normal install +commands. Verification is optional for day-to-day use, but supported for users +who need supply chain integrity checks. + +This process applies to releases produced after the signing workflows were +introduced. Older releases may not have attestations or signatures. + +## Signing Model + +OpenSandbox uses these signing paths: + +- Source code releases: the Generic Release workflow uploads an explicit + `opensandbox-.tar.gz` source archive and `SHA256SUMS` file to the GitHub + Release, then creates GitHub/Sigstore provenance attestations for both files. +- Container images: the component and server image workflows sign Docker Hub + and ACR image digests with `cosign` keyless signing, and publish provenance + attestations to the registries. +- Python and CLI packages: wheels and source distributions are attested before + `uv publish`. +- JavaScript packages: the workflow runs `pnpm pack`, attests the generated npm + tarball, and publishes that same tarball. +- C# packages: NuGet `.nupkg` files are attested before publication. +- Go SDK modules: the `sdks/sandbox/go/v` source release archive is + attested by the Generic Release workflow. +- Helm charts: packaged chart `.tgz` files are attested before upload to the + GitHub Release. +- Java/Kotlin packages: Maven Central publications are signed by the Gradle + Maven publish signing configuration. Download the `.asc` signature next to + the Maven artifact and verify it with OpenPGP tooling. + +Release tags may also be signed with `scripts/release/create-release.sh +--sign-tag` when the release operator has a local git signing key configured. +Do not rely on signed tags alone for generated deliverables; verify the +artifact you are installing. + +## Trust Roots and Keys + +Most OpenSandbox release signatures are keyless Sigstore signatures created by +GitHub Actions OpenID Connect (OIDC). There is no long-lived OpenSandbox private +key for these signatures, so there is no project public key file to download. +The public certificates and signed bundles are retrieved by `gh` or `cosign` +from GitHub's attestation service, OCI registries, and Sigstore transparency +infrastructure. + +Expected identity values: + +- Repository: `opensandbox-group/OpenSandbox` +- OIDC issuer: `https://token.actions.githubusercontent.com` +- Source release workflow: `opensandbox-group/OpenSandbox/.github/workflows/release-generic.yml` +- Component image workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-components.yml` +- Server image workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-server.yml` +- CLI package workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-cli.yml` +- Python package workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-python-sdks.yml` +- JavaScript package workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-js-sdks.yml` +- C# package workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-csharp-sdks.yml` +- Helm chart workflow: `opensandbox-group/OpenSandbox/.github/workflows/publish-helm-chart.yml` + +Set the repository identity for the release you are verifying: + +```bash +REPOSITORY="opensandbox-group/OpenSandbox" +WORKFLOW_REPOSITORY="${REPOSITORY}" +WORKFLOW_REPOSITORY_URL="https://github.com/${WORKFLOW_REPOSITORY}" +``` + +For releases produced before the GitHub organization migration, use the +historical identity instead: + +```bash +REPOSITORY="alibaba/OpenSandbox" +WORKFLOW_REPOSITORY="${REPOSITORY}" +WORKFLOW_REPOSITORY_URL="https://github.com/${WORKFLOW_REPOSITORY}" +``` + +If you run the release workflows from a downstream fork, replace +`opensandbox-group/OpenSandbox` in the verification commands with that fork's +`owner/repository` identity. + +Private signing material is not stored in GitHub Releases, Docker Hub, ACR, +PyPI, npm, Maven Central, NuGet, or Helm chart downloads. Java/Kotlin Maven +Central signing keys are held only in GitHub Actions secrets. + +## Verify Source Releases + +Set the release tag first: + +```bash +TAG="server/v0.1.13" +SAFE_TAG="${TAG//\//-}" +``` + +Download the signed source archive and checksum file: + +```bash +gh release download "$TAG" \ + --repo "$REPOSITORY" \ + --pattern "opensandbox-${SAFE_TAG}.tar.gz" \ + --pattern "SHA256SUMS" +``` + +Check the archive digest: + +```bash +sha256sum -c SHA256SUMS +``` + +Verify the source archive attestation: + +```bash +gh attestation verify "opensandbox-${SAFE_TAG}.tar.gz" \ + --repo "$REPOSITORY" \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/release-generic.yml" +``` + +Verify the checksum file attestation: + +```bash +gh attestation verify SHA256SUMS \ + --repo "$REPOSITORY" \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/release-generic.yml" +``` + +The Generic Release workflow is started with `workflow_dispatch`, so its +provenance `source-ref` is the ref selected when the workflow was dispatched +(normally `refs/heads/main`), not the release tag created by the job. + +## Verify Container Images + +Install `cosign` and `gh`, then resolve the image digest. Always verify by +digest, not by mutable tag alone. + +```bash +IMAGE="docker.io/opensandbox/execd" +TAG="v1.0.15" +DIGEST="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{.Manifest.Digest}}')" +IMAGE_REF="${IMAGE}@${DIGEST}" +``` + +Verify the cosign keyless signature: + +```bash +cosign verify "$IMAGE_REF" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp "^${WORKFLOW_REPOSITORY_URL}/.github/workflows/publish-components.yml@refs/tags/(docker|k8s)/[^/]+/v?[0-9].*$" +``` + +Verify the registry provenance attestation: + +```bash +gh attestation verify "oci://${IMAGE_REF}" \ + --repo "$REPOSITORY" \ + --bundle-from-oci \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/publish-components.yml" +``` + +For the server image, use `docker.io/opensandbox/server` and the server workflow: + +```bash +IMAGE="docker.io/opensandbox/server" +TAG="v0.1.13" +DIGEST="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{.Manifest.Digest}}')" +IMAGE_REF="${IMAGE}@${DIGEST}" + +cosign verify "$IMAGE_REF" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp "^${WORKFLOW_REPOSITORY_URL}/.github/workflows/publish-server.yml@refs/tags/server/v[0-9].*$" +``` + +ACR images use the same digest and identity checks with the ACR image name, for +example: + +```bash +IMAGE="sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd" +``` + +## Verify Packages + +Download the package file from its normal package registry, then verify the +attestation against the OpenSandbox repository and the expected release tag. + +Python and CLI packages: + +```bash +python -m pip download opensandbox-server==0.1.13 --no-deps +gh attestation verify opensandbox_server-0.1.13*.whl \ + --repo "$REPOSITORY" \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/publish-server.yml" \ + --source-ref refs/tags/server/v0.1.13 +``` + +JavaScript packages: + +```bash +npm pack @alibaba-group/opensandbox@0.1.7 +gh attestation verify alibaba-group-opensandbox-0.1.7.tgz \ + --repo "$REPOSITORY" \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/publish-js-sdks.yml" \ + --source-ref refs/tags/js/sandbox/v0.1.7 +``` + +C# packages: + +```bash +gh attestation verify Alibaba.OpenSandbox.1.0.0.nupkg \ + --repo "$REPOSITORY" \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/publish-csharp-sdks.yml" \ + --source-ref refs/tags/csharp/sandbox/v1.0.0 +``` + +Helm charts: + +```bash +gh release download helm/opensandbox/0.1.0 \ + --repo "$REPOSITORY" \ + --pattern 'opensandbox-*.tgz' +gh attestation verify opensandbox-*.tgz \ + --repo "$REPOSITORY" \ + --signer-workflow "${WORKFLOW_REPOSITORY}/.github/workflows/publish-helm-chart.yml" +``` + +When Helm charts are released through `workflow_dispatch`, their provenance +`source-ref` is the selected dispatch ref. Tag-triggered Helm releases have a +tag `source-ref`. + +Java/Kotlin Maven artifacts: + +```bash +curl -O https://repo1.maven.org/maven2/com/alibaba/opensandbox/sandbox/1.0.10/sandbox-1.0.10.jar +curl -O https://repo1.maven.org/maven2/com/alibaba/opensandbox/sandbox/1.0.10/sandbox-1.0.10.jar.asc +KEY_ID="$(gpg --list-packets sandbox-1.0.10.jar.asc | awk '/keyid/ { print $NF; exit }')" +gpg --keyserver hkps://keys.openpgp.org --recv-keys "$KEY_ID" +gpg --verify sandbox-1.0.10.jar.asc sandbox-1.0.10.jar +``` + +If verification cannot find an attestation for a release that predates this +process, use a newer release as the signed release evidence. diff --git a/docs/components/egress.md b/docs/components/egress.md new file mode 100644 index 0000000..a8c5031 --- /dev/null +++ b/docs/components/egress.md @@ -0,0 +1,254 @@ +--- +title: Egress +description: FQDN-based egress control sidecar for OpenSandbox providing DNS filtering, nftables enforcement, and credential injection. +--- + +# OpenSandbox Egress Sidecar + +The **Egress** is a core component of OpenSandbox that provides **FQDN-based egress control**. + +It runs alongside the sandbox application container (sharing the same network namespace) and enforces declared network policies. + +## Features + +- **FQDN-based Allowlist**: Control outbound traffic by domain name (e.g., `api.github.com`). +- **IP / CIDR Targets**: Egress rules can also target literal IP addresses or CIDR ranges (e.g., `10.0.0.0/8`). +- **Wildcard Support**: Allow subdomains using wildcards (e.g., `*.pypi.org`). +- **Transparent Interception**: Uses transparent DNS proxying; no application configuration required. +- **Experimental: Transparent HTTPS MITM (mitmproxy)**: Optional transparent TLS interception for outbound `80/443` traffic in the sidecar network namespace. +- **Dynamic DNS (dns+nft mode)**: When a domain is allowed and the proxy resolves it, the resolved A/AAAA IPs are added to nftables with TTL so that default-deny + domain-allow is enforced at the network layer. +- **Credential Vault**: Automatic credential injection (bearer, basic, API-key, custom headers, and scoped placeholder substitutions) for allowed hosts via transparent mitmproxy. See [Credential Vault](/guides/credential-vault). +- **Privilege Isolation**: Requires `CAP_NET_ADMIN` only for the sidecar; the application container runs unprivileged. +- **Fail-Closed Enforcement**: DNS redirect setup is required through `iptables` or the native nft fallback; the sidecar exits if no enforced redirect can be installed. Optional subsystems (OpenTelemetry, startup hooks) degrade gracefully. + +## Architecture + +The egress control is implemented as a **Sidecar** that shares the network namespace with the sandbox application. + +1. **DNS Proxy (Layer 1)**: + - Runs on `127.0.0.1:15353`. + - `iptables` rules redirect all port 53 (DNS) traffic to this proxy. + - Filters queries based on the allowlist. + - Returns `NXDOMAIN` for denied domains. + +2. **Network Filter (Layer 2)** (when `OPENSANDBOX_EGRESS_MODE=dns+nft`): + - Uses `nftables` to enforce IP-level allow/deny. Resolved IPs for allowed domains are added to dynamic allow sets with TTL (dynamic DNS). + - At startup, the sidecar whitelists **127.0.0.1** (redirect target for the proxy) and **nameserver IPs** from `/etc/resolv.conf` so DNS resolution and proxy upstream work (including private DNS). Nameserver count is capped and invalid IPs are filtered. + +### Kubernetes Service Access Under `defaultAction: deny` + +In Kubernetes deployments that use `defaultAction: deny`, reaching an in-cluster Service usually needs two separate allowances: + +- allow the Service DNS name so the DNS proxy resolves it +- allow the Service CIDR (or a narrower ClusterIP range) so `dns+nft` does not drop the TCP connection after resolution + +Allowing only `postgres.opensandbox.svc.cluster.local` is not sufficient if the resolved ClusterIP still belongs to a denied range such as `10.96.0.0/12`. Likewise, allowing only the CIDR is not sufficient if the DNS proxy still denies the hostname. + +See [Network Isolation](/architecture/network-isolation#allowing-legitimate-in-cluster-services) for operator guidance and examples. + +## Requirements + +- **Runtime**: Docker or Kubernetes. +- **Capabilities**: `CAP_NET_ADMIN` (for the sidecar container only). +- **Kernel**: Linux kernel with `iptables` support. +- **Service mesh**: OpenSandbox egress is not currently supported inside pods that already have a transparent service-mesh sidecar (for example Istio/Envoy injection). Both layers rewrite outbound traffic in the same network namespace and can conflict. + +## Configuration + +Most deployments only need these settings: + +- **Mode**: `OPENSANDBOX_EGRESS_MODE` + - `dns` (default): DNS filtering only + - `dns+nft`: DNS + nftables IP/CIDR enforcement (recommended for strict default-deny) +- **Initial policy**: + - `OPENSANDBOX_EGRESS_RULES` (JSON, same shape as `POST /policy`) + - or `OPENSANDBOX_EGRESS_POLICY_FILE` (if valid file exists, it takes precedence at startup) +- **HTTP API**: + - `OPENSANDBOX_EGRESS_HTTP_ADDR` (default `:18080`) + - `OPENSANDBOX_EGRESS_TOKEN` (optional auth via `OPENSANDBOX-EGRESS-AUTH`) +- **Rule limit**: + - `OPENSANDBOX_EGRESS_MAX_RULES` for `POST/PATCH /policy` (default `4096`, `0` disables cap) + +Optional advanced features: + +- Nameserver bypass: `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT` +- Denied hostname webhook: `OPENSANDBOX_EGRESS_DENY_WEBHOOK`, `OPENSANDBOX_EGRESS_SANDBOX_ID` +- DoH/DoT controls: `OPENSANDBOX_EGRESS_BLOCK_DOH_443`, `OPENSANDBOX_EGRESS_DOH_BLOCKLIST` +- Custom DNS upstream: `OPENSANDBOX_EGRESS_DNS_UPSTREAM` (comma-separated IPs, optional `:port`), `OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT` (default `5` seconds) +- DNS upstream health probe: `OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE` (enable), `OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE_INTERVAL_SEC` +- Credential vault: `OPENSANDBOX_EGRESS_CREDENTIAL_VAULT_REQUIRE_TLS`, `OPENSANDBOX_EGRESS_CREDENTIAL_VAULT_TRUSTED_PROXY_CIDRS`, `OPENSANDBOX_CREDENTIAL_PROXY_SOCKET` (default `/run/opensandbox/credential-proxy/active.sock`) +- Metrics: `OPENSANDBOX_EGRESS_METRICS_EXTRA_ATTRS` (extra key=value attributes for OTLP metrics and structured log fields) + +### Always-Rules Files + +Static rule files under `/var/egress/rules/` are loaded at startup and take priority over dynamic API rules: + +| File | Purpose | +|------|---------| +| `/var/egress/rules/deny.always` | Domains always denied, overrides user and allow rules | +| `/var/egress/rules/allow.always` | Domains always allowed, overrides user rules | +| `/var/egress/rules/log_skip.always` | Domain patterns whose DNS blocks are not logged (noise reduction) | + +Format: one domain per line (supports wildcards like `*.example.com`). Lines starting with `#` are comments. Missing files are silently ignored. + +Rule precedence: `deny.always` > `allow.always` > user policy (API/env). + +Always-rules are hot-reloaded: the sidecar polls the files once per minute and applies changes without restart. + +### Service Mesh Compatibility + +::: warning Not Supported with Transparent Mesh Sidecars +OpenSandbox egress is designed to be the only transparent outbound interception layer inside the sandbox pod. Deployments that automatically inject a service-mesh sidecar such as Istio/Envoy into the same pod are not currently supported for egress-sidecar features. +::: + +Why this conflicts today: + +- OpenSandbox egress installs `iptables`/`nft` redirect rules in the shared pod network namespace so DNS and optional HTTPS MITM traffic flow through the egress sidecar. +- Service meshes such as Istio also redirect outbound traffic in that same namespace, usually to Envoy. +- When both are present, the redirect order becomes deployment-dependent and can produce double interception, broken TLS, or traffic that bypasses the expected Credential Vault / egress-policy path. + +This matters for: + +- per-sandbox `networkPolicy` / `network_policy` enforcement +- transparent mitmproxy mode +- Credential Vault / Credential Proxy + +Recommended operator choices today: + +1. Exclude OpenSandbox sandbox pods from automatic mesh sidecar injection when they need the egress sidecar. +2. If mesh injection is mandatory, do not rely on the OpenSandbox egress sidecar for outbound control in those pods; instead use a platform-level mechanism such as a CNI/network-policy solution. +3. Treat mesh-injected sandboxes as a separate runtime profile and document that Credential Vault and transparent egress interception are unavailable there until first-class coexistence support is implemented. + +See also [Credential Vault](/guides/credential-vault) and [Network Isolation](/architecture/network-isolation). + +### Runtime HTTP API + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/policy` | Get current policy and enforcement mode | +| `POST` | `/policy` | Replace policy (`{}`, `null`, empty body => reset to deny-all) | +| `PUT` | `/policy` | Alias for `POST` | +| `PATCH` | `/policy` | Merge/append rules (body is JSON array of egress rules) | +| `DELETE` | `/policy` | Remove specific targets (body is JSON string array, e.g. `["*.example.com"]`) | +| `GET/POST/PATCH/DELETE` | `/credential-vault` | Manage the credential vault (create, update, delete) | +| `GET` | `/credential-vault/credentials` | List credential metadata | +| `GET` | `/credential-vault/credentials/{name}` | Get single credential metadata | +| `GET` | `/credential-vault/bindings` | List binding metadata | +| `GET` | `/credential-vault/bindings/{name}` | Get single binding metadata | +| `GET` | `/healthz` | Health check; returns `200 ok` or `503 mitmproxy not ready` (when transparent MITM is enabled but not yet initialized) | + +Quick example: + +```bash +# Replace policy +curl -XPOST http://127.0.0.1:18080/policy \ + -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.example.com"}]}' + +# Remove specific targets +curl -XDELETE http://127.0.0.1:18080/policy \ + -d '["*.example.com"]' +``` + +### Experimental: Transparent MITM (mitmproxy) + +::: warning Experimental +APIs, environment variables, and behavior may change. +::: + +Optional transparent HTTPS interception for outbound `80/443` traffic in the sidecar network namespace. + +### Credential Vault + +The credential vault provides automatic credential injection for outbound requests to allowed hosts. Credentials are stored in-memory and injected into matching requests by the transparent mitmproxy layer. + +Prerequisites: transparent mitmproxy enabled (`OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true`), egress API auth token set (`OPENSANDBOX_EGRESS_TOKEN`). + +Supported auth types: `bearer`, `basic`, `apiKey`, `customHeaders`. + +See [Credential Vault](/guides/credential-vault) for full API usage, binding rules, and security model. + +### Observability (OpenTelemetry) + +Egress can export **OTLP metrics**; application logs use the **native zap** logger (JSON to stdout by default, configurable via `OPENSANDBOX_LOG_OUTPUT` / `OPENSANDBOX_EGRESS_LOG_LEVEL`). OTLP log export is not used. + +## Build & Run + +### Build Docker Image + +```bash +cd components/egress + +# Build locally +docker build -t opensandbox/egress:local . + +# Or use the build script (multi-arch) +./build.sh +``` + +### Run Locally + +1. Start sidecar: + +```bash +docker run -d --name sandbox-egress \ + --cap-add=NET_ADMIN \ + opensandbox/egress:local +``` + +2. Apply policy: + +```bash +curl -XPOST http://127.0.0.1:18080/policy \ + -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.google.com"}]}' +``` + +3. Run app container in the same network namespace: + +```bash +docker run --rm -it \ + --network container:sandbox-egress \ + curlimages/curl sh +``` + +4. Verify from app container: + +```bash +curl -I https://google.com +curl -I https://github.com +``` + +## Development + +- **Language**: Go 1.25+ +- **Key Packages**: + - `pkg/dnsproxy`: DNS server and policy matching logic. + - `pkg/iptables`: `iptables` rule management. + - `pkg/nftables`: nftables static/dynamic rules and DNS-resolved IP sets. + - `pkg/policy`: Policy parsing and definition. + - `pkg/credentialvault`: Credential vault store and binding validation. + - `pkg/startup`: Post-startup hook registry (`Register`/`RunPost`). + - `hooks/`: Side-effect import target; `init()` functions register startup hooks that run after iptables/MITM setup. + +```bash +cd components/egress +go test ./... +``` + +## Process Supervisor + +The egress container runs under `opensandbox-supervisor`, a lightweight process wrapper that restarts the egress worker on crash with exponential backoff, a crashloop circuit breaker, and structured JSONL event logging. + +``` +ENTRYPOINT: supervisor --pre-start=cleanup.sh --name=egress --grace-period=20s -- /opt/opensandbox-egress/egress +``` + +Egress-specific configuration: + +- **`--grace-period=20s`**: Egress needs extra time to drain DNS connections and tear down iptables/nft rules on shutdown (default is 10 s). +- **Pre-start hook** (`cleanup.sh`): Reaps orphaned `mitmdump` processes from a previous crash and removes stale DNS redirect iptables/native nft state that would otherwise point port 53 at a dead proxy. It does not manage the `inet opensandbox` policy table; the nftables manager deletes and recreates that table when policy enforcement starts. + +## Troubleshooting + +- **"iptables setup failed"**: ensure sidecar has `--cap-add=NET_ADMIN`. +- **DNS fails for all domains**: check sidecar upstream DNS reachability and logs. +- **Traffic not blocked as expected**: in `dns+nft`, verify nft applied (`nft list table inet opensandbox`) and check sidecar logs for fallback. diff --git a/docs/components/execd.md b/docs/components/execd.md new file mode 100644 index 0000000..b67bf80 --- /dev/null +++ b/docs/components/execd.md @@ -0,0 +1,171 @@ +--- +title: execd +description: The in-sandbox execution daemon providing HTTP APIs for code execution, shell commands, filesystem operations, PTY sessions, and metrics. +--- + +# execd - OpenSandbox Execution Daemon + +`execd` is the runtime daemon used inside OpenSandbox sandboxes. + +It is built on Gin and exposes HTTP APIs for code execution, shell commands, filesystem operations, PTY sessions, and metrics. + +## Quick Start + +### 1) Build + +```bash +cd components/execd +make build +``` + +### 2) Start Jupyter Server + +```bash +./tests/jupyter.sh +``` + +### 3) Run execd + +```bash +./bin/execd \ + --jupyter-host=http://127.0.0.1:54321 \ + --jupyter-token=your-jupyter-token \ + --port=44772 +``` + +### 4) Verify + +```bash +curl -v http://localhost:44772/ping +``` + +## API + +- OpenAPI spec: [execd-api.yaml](/api/) +- Common capability groups: + - Code execution (`/code`, SSE stream) + - Session and command execution (`/session`, `/command`) + - Filesystem operations (`/files`, `/directories`) + - Isolated sessions (`/v1/isolated/session`, bubblewrap namespaces) + - PTY over WebSocket (`/pty`) + - Local metrics endpoints (`/metrics`, `/metrics/watch`) + +## Isolated Sessions + +Isolated sessions run a bash process inside a per-execution +[bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) namespace, +created via `POST /v1/isolated/session`. Beyond the workspace, callers can +expose additional host paths into the namespace. + +### Bind mounts + +Two request fields control extra host paths: + +- `extra_writable`: a list of paths bind-mounted read-write at the same path + inside the namespace (`source == destination`). +- `binds`: explicit `source` → `dest` mappings, each optionally read-only. + - `source` (required): host path to bind. It must **already exist** and is + resolved (symlinks followed) before use. + - `dest`: mount destination inside the namespace; defaults to `source` when + omitted. It must be an **existing** mount point — `bwrap` cannot create a + destination under the read-only root, so create the directory first. + - `readonly` (default `false`): mount read-only (`--ro-bind`) when `true`, + read-write (`--bind`) otherwise. + +Example: + +```json +{ + "workspace": { "path": "/workspace", "mode": "rw" }, + "binds": [ + { "source": "/data/in", "dest": "/mnt/in", "readonly": true }, + { "source": "/data/out", "dest": "/mnt/out" } + ] +} +``` + +### Writable allowlist + +The source path of every `extra_writable` entry and every `binds` entry must +fall within the `allowed_writable` allowlist (see the isolation config file +below). The allowlist is enforced against the fully symlink-resolved real +path, so a symlink cannot redirect a bind outside the allowlist. An empty +allowlist rejects all `extra_writable`/`binds` requests. + +The built-in default allowlist is `/workspace`, `/mnt`, `/media`, `/data` +(subpaths included). Set `allowed_writable` in the isolation config to +override it. + +## Configuration + +### CLI Flags + +| Flag | Default | Description | +|---|---|---| +| `--jupyter-host` | `""` | Jupyter server URL reachable by execd. | +| `--jupyter-token` | `""` | Jupyter token for HTTP/WebSocket auth. | +| `--port` | `44772` | HTTP listen port. | +| `--log-level` | `6` | Log level (0=Emergency, 7=Debug). | +| `--access-token` | `""` | Optional shared API access token. | +| `--graceful-shutdown-timeout` | `1s` | SSE tail-drain wait window before closing. | +| `--jupyter-idle-poll-interval` | `100ms` | Poll interval after Jupyter reports idle. | +| `--isolation-config` | `""` | Path to the isolation TOML config (see below). | + +### Environment Variables + +| Variable | Description | +|---|---| +| `JUPYTER_HOST` | Same as `--jupyter-host` (overridden by explicit flag). | +| `JUPYTER_TOKEN` | Same as `--jupyter-token` (overridden by explicit flag). | +| `EXECD_ACCESS_TOKEN` | Same as `--access-token` (overridden by explicit flag). | +| `EXECD_API_GRACE_SHUTDOWN` | Same as `--graceful-shutdown-timeout`. | +| `EXECD_JUPYTER_IDLE_POLL_INTERVAL` | Same as `--jupyter-idle-poll-interval`. | +| `EXECD_ISOLATION_CONFIG` | Same as `--isolation-config`. | +| `EXECD_CLONE3_COMPAT` | Linux clone3 compatibility switch (see below). | +| `EXECD_LOG_FILE` | Optional log output file path; default is stdout. | +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Preferred OTLP metrics endpoint. | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Fallback OTLP endpoint when metrics-specific endpoint is unset. | +| `OPENSANDBOX_ID` | Optional `sandbox_id` metric/resource attribute. | +| `OPENSANDBOX_EXECD_METRICS_EXTRA_ATTRS` | Optional extra metric attrs (`k=v,k2=v2`). | + +### Isolation Config File + +Isolated sessions read an optional TOML file given by `--isolation-config` +(or `EXECD_ISOLATION_CONFIG`). All fields are optional; omitted fields use +built-in defaults. + +```toml +# Parent directory for per-session overlay upper directories. +upper_root = "/var/lib/execd/isolation" + +# Host paths callers may request via extra_writable / binds. +# Enforced against the fully symlink-resolved real path; subpaths are allowed. +# Default: ["/workspace", "/mnt", "/media", "/data"]. Empty = reject all. +allowed_writable = ["/workspace", "/mnt", "/media", "/data"] +``` + +## Observability + +### OpenTelemetry Metrics + +OTLP metrics export is enabled when either endpoint is set: + +- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` +- `OTEL_EXPORTER_OTLP_ENDPOINT` + +### Local Metrics Endpoints + +- `GET /metrics`: point-in-time host metrics snapshot +- `GET /metrics/watch`: SSE stream (1s cadence) + +## Linux clone3 Compatibility + +Some sandbox environments fail on `clone3(2)`. +Set `EXECD_CLONE3_COMPAT` in sandbox env to force fallback behavior: + +- `1` / `true` / `yes` / `on`: enable seccomp fallback +- `reexec`: enable fallback and re-exec binary + +## License + +`execd` is part of OpenSandbox. See the [LICENSE](https://github.com/opensandbox-group/OpenSandbox/blob/main/LICENSE). diff --git a/docs/components/index.md b/docs/components/index.md new file mode 100644 index 0000000..03e7fc1 --- /dev/null +++ b/docs/components/index.md @@ -0,0 +1,38 @@ +--- +title: Components +description: Overview of OpenSandbox system components — server, execd, ingress, and egress. +--- + +# Components + +OpenSandbox is composed of several system components that work together to provide sandbox lifecycle management, in-sandbox execution, and network control. + +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ +│ SDK/CLI │────▶│ Ingress │──┐ +└─────────────┘ └─────────────┘ │ + ▼ +┌─────────────┐ ┌─────────────────────────┐ +│ Server │────▶│ Sandbox │ +│ (Control │ │ ┌───────┐ ┌─────────┐ │ +│ Plane) │ │ │ execd │ │ egress │ │ +└─────────────┘ │ └───────┘ └─────────┘ │ + └─────────────────────────┘ +``` + +## Components + +| Component | Description | Details | +|-----------|-------------|---------| +| [Server](/components/server) | FastAPI-based lifecycle control plane. Creates, monitors, and terminates sandboxes across Docker and Kubernetes. | Python, REST API | +| [Execd](/components/execd) | In-sandbox execution daemon. Provides HTTP APIs for shell commands, file operations, PTY sessions, and code interpreters. | Go, Gin framework | +| [Ingress](/components/ingress) | HTTP/WebSocket reverse proxy for Kubernetes sandbox routing. Routes traffic to sandbox instances via header or URI mode. | Go | +| [Egress](/components/egress) | Per-sandbox FQDN-based egress control sidecar. Enforces allowlists, credential injection, and network policy. | Go | + +## Related + +- [Architecture Overview](/architecture/) — How components fit together +- [Kubernetes Deployment](/kubernetes/) — Deploying components on Kubernetes +- [API Specs](/api/) — OpenAPI specifications diff --git a/docs/components/ingress.md b/docs/components/ingress.md new file mode 100644 index 0000000..c297fe1 --- /dev/null +++ b/docs/components/ingress.md @@ -0,0 +1,169 @@ +--- +title: Ingress +description: HTTP/WebSocket reverse proxy that routes traffic to OpenSandbox instances via header or URI-based routing modes. +--- + +# OpenSandbox Ingress + +## Overview +- HTTP/WebSocket reverse proxy that routes to sandbox instances. +- Watches sandbox CRs (BatchSandbox or AgentSandbox, chosen by `--provider-type`) across all namespaces: + - BatchSandbox: reads endpoints from `sandbox.opensandbox.io/endpoints` annotation. + - AgentSandbox: reads `status.serviceFQDN`. +- Exposes `/status.ok` health check; prints build metadata (version, commit, time, Go/platform) at startup. + +## Quick Start +```bash +cd components/ingress + +go run main.go \ + --namespace \ + --provider-type \ + --mode \ + --port 28888 \ + --log-level info +``` +Endpoints: `/` (proxy), `/status.ok` (health). + +## Routing Modes + +The ingress supports two routing modes for discovering sandbox instances: + +### Header Mode (default: `--mode header`) + +Routes requests based on the `OpenSandbox-Ingress-To` header or the `Host` header. + +**Format:** +- Header: `OpenSandbox-Ingress-To: -` +- Host: `-.` + +**Example:** +```bash +# Using OpenSandbox-Ingress-To header +curl -H "OpenSandbox-Ingress-To: my-sandbox-8080" https://ingress.opensandbox.io/api/users + +# Using Host header +curl -H "Host: my-sandbox-8080.example.com" https://ingress.opensandbox.io/api/users +``` + +**Parsing logic:** +- Extracts sandbox ID and port from the format `-` +- The last segment after the last `-` is treated as the port +- Everything before the last `-` is treated as the sandbox ID + +### URI Mode (`--mode uri`) + +Routes requests based on the URI path structure. + +**Format:** + +`///` + +**Example:** +```bash +# Request to sandbox "my-sandbox" on port 8080, forwarding to /api/users +curl https://ingress.opensandbox.io/my-sandbox/8080/api/users + +# WebSocket example +wss://ingress.opensandbox.io/my-sandbox/8080/ws +``` + +**Parsing logic:** +- First path segment: sandbox ID +- Second path segment: sandbox port +- Remaining path: forwarded to the target sandbox as the request URI +- If no remaining path is provided, defaults to `/` + +**Use cases:** +- When you cannot modify HTTP headers +- When you need path-based routing +- For simpler client configuration without custom headers + +## Auto-Renew on Ingress Access (OSEP-0009) + +When enabled, the ingress publishes **renew-intent** events to a Redis list on each proxied request (after resolving the sandbox). The OpenSandbox server consumes these events and may extend sandbox expiration for sandboxes that opted in at creation time. + +::: info Requirements +The server must have `renew_intent` (and Redis consumer for ingress mode) enabled; the sandbox must opt in via `extensions["access.renew.extend.seconds"]` (decimal integer string between **300** and **86400** seconds). This feature is best-effort and disabled by default. +::: + +| Flag | Default | Description | +|------|---------|-------------| +| `--renew-intent-enabled` | `false` | Enable publishing renew-intent events to Redis | +| `--renew-intent-redis-dsn` | `redis://127.0.0.1:6379/0` | Redis DSN (may include `user:password@`) | +| `--renew-intent-queue-key` | `opensandbox:renew:intent` | Redis List key for intent payloads | +| `--renew-intent-queue-max-len` | `0` | Max list length (0 = no cap); LTRIM applied when > 0 | +| `--renew-intent-min-interval` | `60` | Min seconds between intents per sandbox (client-side throttle) | + +**Example (with Redis):** +```bash +go run main.go \ + --namespace opensandbox \ + --renew-intent-enabled \ + --renew-intent-redis-dsn "redis://user:pass@redis:6379/0" \ + --renew-intent-min-interval 120 +``` + +## Build +```bash +cd components/ingress +make build +# override build metadata if needed +VERSION=1.2.3 GIT_COMMIT=$(git rev-parse HEAD) BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") make build +``` + +## Docker Build +Dockerfile already wires ldflags via build args: +```bash +docker build \ + --build-arg VERSION=$(git describe --tags --always --dirty) \ + --build-arg GIT_COMMIT=$(git rev-parse HEAD) \ + --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + -t opensandbox/ingress:local . +``` + +## Multi-arch Publish Script +`build.sh` uses buildx to build/push linux/amd64 and linux/arm64: +```bash +cd components/ingress +TAG=local VERSION=1.2.3 GIT_COMMIT=abc BUILD_TIME=2025-01-01T00:00:00Z bash build.sh +``` + +## Runtime Requirements +- Access to Kubernetes API (in-cluster or via KUBECONFIG). +- If `--provider-type=batchsandbox`: BatchSandbox CRs in any namespace with `sandbox.opensandbox.io/endpoints` annotation containing Pod IPs. +- If `--provider-type=agent-sandbox`: AgentSandbox CRs in any namespace with `status.serviceFQDN` populated. + +## Implementation Notes + +### Header Mode Behavior +- Routing key priority: `OpenSandbox-Ingress-To` header first, otherwise Host parsing `-.*`. +- Sandbox name extracted from request is used to query the sandbox CR (BatchSandbox or AgentSandbox) via informer cache: + - BatchSandbox: endpoints annotation. + - AgentSandbox: `status.serviceFQDN`. +- The original request path is preserved and forwarded to the target sandbox. + +### URI Mode Behavior +- Routing information is extracted from the URI path: `///`. +- The sandbox ID and port are extracted from the first two path segments. +- The remaining path (`/`) is forwarded to the target sandbox as the request URI. +- If no remaining path is provided, the request URI defaults to `/`. + +### Commons +- Error handling: + - `ErrSandboxNotFound` (sandbox resource not exists) -> HTTP 404 + - `ErrSandboxNotReady` (not enough replicas, missing endpoints, invalid config) -> HTTP 503 + - Other errors (K8s API errors, etc.) -> HTTP 502 +- WebSocket path forwards essential headers and X-Forwarded-*; HTTP path strips `OpenSandbox-Ingress-To` before proxying (header mode only). + +## Development & Tests +```bash +cd components/ingress +go test ./... +``` + +Key code: +- `main.go`: entrypoint and handlers. +- `pkg/proxy/`: HTTP/WebSocket proxy logic, sandbox endpoint resolution. +- `pkg/sandbox/`: Sandbox provider abstraction and BatchSandbox implementation. +- `version/`: build metadata output (populated via ldflags). diff --git a/docs/components/server.md b/docs/components/server.md new file mode 100644 index 0000000..7b8f7b1 --- /dev/null +++ b/docs/components/server.md @@ -0,0 +1,252 @@ +--- +title: Server +description: FastAPI-based control plane for managing the lifecycle of containerized sandboxes across Docker and Kubernetes runtimes. +--- + +# OpenSandbox Server + +A production-grade, FastAPI-based service for managing the lifecycle of containerized sandboxes. It acts as the control plane to create, run, monitor, and dispose isolated execution environments across container platforms. + +## Features + +### Core capabilities +- **Lifecycle APIs**: Standardized REST interfaces for create, start, pause, resume, delete +- **Pluggable runtimes**: + - **Docker**: Production-ready + - **Kubernetes**: Production-ready (see [Kubernetes Controller](/kubernetes/) for deployment) +- **Lifecycle cleanup modes**: Configurable TTL with renewal, or manual cleanup with explicit delete +- **Access control**: API Key authentication (`OPEN-SANDBOX-API-KEY`); can be disabled for local/dev +- **Networking modes**: + - Host: shared host network, performance first + - Bridge: isolated network with built-in HTTP routing +- **Resource quotas**: CPU/memory limits with Kubernetes-style specs +- **Observability**: Unified status with transition tracking +- **Registry support**: Public and private images + +### Extended capabilities +- **Async provisioning**: Background creation to reduce latency +- **Timer restoration**: Expiration timers restored after restart +- **Env/metadata injection**: Per-sandbox environment and metadata +- **Port resolution**: Dynamic endpoint generation +- **Structured errors**: Standard error codes and messages + +::: warning +Metadata keys under the reserved prefix `opensandbox.io/` are system-managed and cannot be supplied by users. +::: + +## Requirements + +- **Python**: 3.10 or higher +- **Package Manager**: [uv](https://github.com/astral-sh/uv) (recommended) or pip +- **Runtime Backend**: + - Docker Engine 20.10+ (for Docker runtime) + - Kubernetes 1.21.1+ (for Kubernetes runtime) +- **Operating System**: Linux, macOS, or Windows with WSL2 + +## Quick Start + +### Installation + +Install from PyPI. For local development, clone the repo and run `uv sync` in `server/`. + +```bash +uv pip install opensandbox-server +``` + +### Configuration + +The server reads a **TOML** file. Default path: `~/.sandbox.toml`. Override with **`SANDBOX_CONFIG_PATH`** or **`opensandbox-server --config /path/to/sandbox.toml`**. + +1. Generate a starter file (see `opensandbox-server -h` for all flags): + +```bash +opensandbox-server init-config ~/.sandbox.toml --example docker +# Kubernetes: --example k8s (deploy the operator / CRDs per kubernetes/ first) +# Locales: docker-zh | k8s-zh | omit --example for a schema-only skeleton | add --force to overwrite +``` + +2. Edit the file for your environment. Full reference: [configuration.md](https://github.com/opensandbox-group/OpenSandbox/blob/main/server/configuration.md) (all keys, defaults, validation, env vars). + + Topics covered there include: Docker `network_mode` / `host_ip` (e.g. server in Docker Compose), `[egress]` when clients send `networkPolicy`, `[ingress]`, `[secure_runtime]`, Kubernetes `workload_provider` / `batchsandbox_template_file`, `[agent_sandbox]`, TTL caps, `[renew_intent]`. + The server-wide persistence backend is configured under `[store]`; by default OpenSandbox uses a local SQLite database at `~/.opensandbox/opensandbox.db` for server-managed metadata such as snapshot records. + +### Run the server + +```bash +opensandbox-server +# opensandbox-server --config /path/to/sandbox.toml +``` + +Listens on `server.host` / `server.port` from your TOML (defaults in [configuration.md](https://github.com/opensandbox-group/OpenSandbox/blob/main/server/configuration.md)). + +**Health check** (adjust host/port if you changed them): + +```bash +curl http://127.0.0.1:8080/health +# -> {"status": "healthy"} +``` + +## API Documentation + +Once the server is running, interactive API documentation is available: + +- **Swagger UI**: [http://localhost:8080/docs](http://localhost:8080/docs) +- **ReDoc**: [http://localhost:8080/redoc](http://localhost:8080/redoc) + +### API Authentication + +Authentication is enforced only when `server.api_key` is set. If the value is empty or missing, the middleware skips API Key checks; however startup requires explicit risk acknowledgment. In interactive TTY mode, type `YES` when prompted. In non-interactive environments (Docker/Kubernetes/CI), set `OPENSANDBOX_INSECURE_SERVER=YES` to proceed. For production, always set a non-empty `server.api_key` and send it via the `OPEN-SANDBOX-API-KEY` header. + +::: warning +Strongly recommend enabling `server.api_key`. See [security report Issue #750](https://github.com/opensandbox-group/OpenSandbox/issues/750). +::: + +All API endpoints (except `/health`, `/docs`, `/redoc`) require authentication via the `OPEN-SANDBOX-API-KEY` header when authentication is enabled: + +```bash +curl -H "OPEN-SANDBOX-API-KEY: your-secret-api-key" http://localhost:8080/v1/sandboxes +``` + +### Example Usage + +**Create a Sandbox** + +```bash +curl -X POST "http://localhost:8080/v1/sandboxes" \ + -H "OPEN-SANDBOX-API-KEY: your-secret-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "image": { + "uri": "python:3.11-slim" + }, + "entrypoint": [ + "python", + "-m", + "http.server", + "8000" + ], + "timeout": 3600, + "resourceLimits": { + "cpu": "500m", + "memory": "512Mi" + }, + "env": { + "PYTHONUNBUFFERED": "1" + }, + "metadata": { + "team": "backend", + "project": "api-testing" + } + }' +``` + +Response: +```json +{ + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "status": { + "state": "Pending", + "reason": "CONTAINER_STARTING", + "message": "Sandbox container is starting.", + "lastTransitionAt": "2024-01-15T10:30:00Z" + }, + "metadata": { + "team": "backend", + "project": "api-testing" + }, + "expiresAt": "2024-01-15T11:30:00Z", + "createdAt": "2024-01-15T10:30:00Z", + "entrypoint": ["python", "-m", "http.server", "8000"] +} +``` + +**Other lifecycle calls** (same `OPEN-SANDBOX-API-KEY` header): `GET /v1/sandboxes/{id}`, `POST /v1/sandboxes/{id}/pause`, `POST /v1/sandboxes/{id}/resume`, `GET /v1/sandboxes/{id}/endpoints/{port}` (append `?use_server_proxy=true` when needed), `POST .../renew-expiration`, `DELETE /v1/sandboxes/{id}`. Full request/response shapes: **Swagger UI** above or OpenAPI under [specs/](/api/). + +For Kubernetes-backed sandboxes, pause/resume is implemented via `BatchSandbox.spec.pause` and internal `SandboxSnapshot` resources. The externally visible lifecycle transitions are `Running -> Pausing -> Paused -> Resuming -> Running`. + +`secureAccess` currently applies only to **Kubernetes** sandboxes exposed through **ingress gateway mode**. Direct endpoint exposure, including non-gateway ingress configurations, is not supported for secured access. + +## Architecture + +### Component Responsibilities + +- **API Layer** (`opensandbox_server/api/`): HTTP request handling, validation, and response formatting +- **Service Layer** (`opensandbox_server/services/`): Business logic for sandbox lifecycle operations +- **Middleware** (`opensandbox_server/middleware/`): Cross-cutting concerns (authentication, logging) +- **Configuration** (`opensandbox_server/config.py`): Centralized configuration management +- **Runtime Implementations**: Platform-specific sandbox orchestration + +### Sandbox Lifecycle States + +``` + create() + | + v + +---------+ + | Pending |--------------------+ + +----+----+ | + | | + | (provisioning) | + v | + +---------+ pause() | + | Running |---------------+ | + +----+----+ | | + | | | + | resume() | | + | +--------------+ | | + | | | | | + | v | | | + | +--------+ | | | + +-| Paused |-------+ | | + | +----+---+ | | + | | | | + | v | | + | +----------+ | | + | | Resuming |------+ | + | +----------+ | + | | + | delete() or expire() | + v | + +----------+ | + | Stopping | | + +----+-----+ | + | | + +----------------+--------+ + | | + v v + +------------+ +--------+ + | Terminated | | Failed | + +------------+ +--------+ +``` + +## Experimental Features + +Optional experimental behavior; off by default. See release notes before production. + +### Auto-Renew on Access + +Extends sandbox TTL when traffic is observed (lifecycle proxy and/or ingress + optional Redis queue). Per-sandbox: on create, set `extensions["access.renew.extend.seconds"]` (string integer 300-86400). Clients using the server proxy: request endpoints with `use_server_proxy=true` (REST) or SDK `ConnectionConfig(..., use_server_proxy=True)`. + +## Development + +### Code Quality + +```bash +cd server +uv run ruff check # Run linter +uv run ruff check --fix # Auto-fix issues +uv run ruff format # Format code +``` + +### Testing + +```bash +cd server +uv run pytest # Run all tests +uv run pytest --cov=opensandbox_server --cov-report=term --cov-fail-under=80 # With coverage +uv run pytest tests/test_docker_service.py::test_create_sandbox_requires_entrypoint # Specific test +``` + +## License + +This project is licensed under the terms specified in the LICENSE file in the repository root. diff --git a/docs/examples/agent-sandbox.md b/docs/examples/agent-sandbox.md new file mode 100644 index 0000000..ebeed23 --- /dev/null +++ b/docs/examples/agent-sandbox.md @@ -0,0 +1,63 @@ +--- +title: Agent Sandbox +description: Create a kubernetes-sigs/agent-sandbox instance and run a command using the OpenSandbox Python SDK. +--- + +# Agent-Sandbox Example + +This example creates a sandbox backed by `kubernetes-sigs/agent-sandbox` and +executes `echo hello world` via the OpenSandbox Python SDK. + +## Prerequisites + +- A Kubernetes cluster with the agent-sandbox controller and CRDs installed. +- OpenSandbox server configured with Kubernetes runtime and `workload_provider = "agent-sandbox"`. +- Sandbox image should include `bash` (default example uses `ubuntu:22.04`). + +## Start OpenSandbox server + +1. Install the server package and fetch the example config for agent-sandbox: + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +``` + +2. Update `~/.sandbox.toml` with the following sections: + +```toml +[runtime] +type = "kubernetes" +execd_image = "opensandbox/execd:v1.0.21" + +[kubernetes] +namespace = "default" +# kubeconfig_path = "/absolute/path/to/kubeconfig" # optional if running in-cluster +workload_provider = "agent-sandbox" + +[agent_sandbox] +shutdown_policy = "Delete" +``` + +3. Start the server: + +```shell +opensandbox-server +``` + +## Run the example + +```shell +uv pip install opensandbox +uv run python examples/agent-sandbox/main.py +``` + +## Expected output + +```text +command output: hello world +``` + +## References + +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/agent-sandbox) diff --git a/docs/examples/aio-sandbox.md b/docs/examples/aio-sandbox.md new file mode 100644 index 0000000..a5ef609 --- /dev/null +++ b/docs/examples/aio-sandbox.md @@ -0,0 +1,86 @@ +--- +title: AIO Sandbox +description: Create and access an All-in-One (AIO) Sandbox via OpenSandbox. +--- + +# All-in-One (AIO) Sandbox Example + +This example demonstrates how to create and access an [All-in-One (AIO) Sandbox](https://github.com/agent-infra/sandbox) via OpenSandbox. + +## Start OpenSandbox server [local] + +You can find the latest version [here](https://github.com/agent-infra/sandbox/pkgs/container/sandbox). + +You can pre-pull the target image which is used in the example. + +::: info Docker runtime requirement +The server is configured with `runtime.type = "docker"` by default, so it **must** be able to connect to a running Docker daemon. + +- **Docker Desktop**: ensure Docker Desktop is running, then verify with `docker version`. +- **Colima (macOS)**: start it first (`colima start`) and export the socket before starting the server: + +```shell +export DOCKER_HOST="unix://${HOME}/.colima/default/docker.sock" +``` +::: + +```shell +# pre-pull target image +docker pull ghcr.io/agent-infra/sandbox:latest +``` + +Then, start the OpenSandbox server, you can obtain stdout log from terminal. + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +opensandbox-server +``` + +::: tip +`opensandbox-server` runs in the foreground and will keep the current terminal session busy. The example code lives in this repository -- clone it and, in a new terminal window/tab, `cd` into the project root before running the AIO sandbox creation steps below. + +If you see errors like `FileNotFoundError: [Errno 2] No such file or directory` from `docker/transport/unixconn.py`, it usually means the Docker unix socket is missing / Docker daemon is not running. +::: + +## Create and Access the AIO Sandbox Instance + +This example uses a fixed configuration for quick start: +- OpenSandbox server: `http://localhost:8080` +- Image: `ghcr.io/agent-infra/sandbox:latest` +- AIO port: `8080` +- Timeout: `300s` + +Install dependencies with uv under project root: + +```shell +uv pip install opensandbox agent-sandbox==0.0.18 +``` + +Run the example (it will create a sandbox via OpenSandbox, wait until it's Running, then connect to it via agent-sandbox): + +```shell +uv run python examples/aio-sandbox/main.py +``` + +Subsequently, you will instantiate an AIO sandbox, navigate to Google, capture a screenshot, and download it to your local environment. + +```text +Creating AIO sandbox with image=ghcr.io/agent-infra/sandbox:latest on OpenSandbox server http://localhost:8080... +[check] sandbox ready after 7.1s +AIO portal endpoint: 127.0.0.1:56123 +... +Screenshot saved to sandbox_screenshot.png +``` + +![AIO Sandbox screenshot](../public/images/aio-sandbox-screenshot.png) + +## More examples + +For more examples of using the AIO Sandbox, refer to agent-infra/sandbox [examples](https://github.com/agent-infra/sandbox/tree/main/examples). + +## References + +- [AIO Sandbox](https://github.com/agent-infra/sandbox/tree/main) +- [AIO Sandbox Python SDK](https://github.com/agent-infra/sandbox/tree/main/sdk/python) +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/aio-sandbox) diff --git a/docs/examples/aks-kata.md b/docs/examples/aks-kata.md new file mode 100644 index 0000000..0f9fbdb --- /dev/null +++ b/docs/examples/aks-kata.md @@ -0,0 +1,379 @@ +--- +title: AKS + Kata +description: Deploy OpenSandbox onto an AKS cluster with Kata VM isolation and interact with a Kata-isolated sandbox step by step, covering ingress, egress, and Credential Vault. +--- + +# AKS Kata Example + +Deploy OpenSandbox onto an AKS cluster with `kata-vm-isolation`, then interact with a Kata-isolated sandbox step by step. + +::: warning Demo credentials +The Helm values in this example use **insecure, well-known demo credentials** +(`api_key = "aks-kata-demo-key"` and a fixed `secureAccess` signing key) so the +walkthrough works out of the box with local `kubectl port-forward`. **Change +them before exposing the server or gateway beyond local port-forwarding.** See +[Security Model](#security-model). +::: + +## Prerequisites + +- AKS cluster with `kubectl get runtimeclass` showing `kata-vm-isolation` +- Helm 3 +- Python 3.10+ with `pip install opensandbox requests` +- Azure OpenAI resource (endpoint + API key) + +## Files + +The example lives in [`examples/aks-kata`](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/aks-kata): + +| File | Purpose | +|------|---------| +| `main.py` | CLI tool — create, inspect, and operate on sandboxes one step at a time | +| `controller-values.yaml` | Helm values for the OpenSandbox controller | +| `server-values.yaml` | Helm values for the lifecycle server and ingress gateway | +| `batchsandbox-template-configmap.yaml` | BatchSandbox template that pins pods onto Kata nodes | + +## 1. Install OpenSandbox + +From the repo root: + +```bash +kubectl create namespace opensandbox-system --dry-run=client -o yaml | kubectl apply -f - +kubectl create namespace opensandbox --dry-run=client -o yaml | kubectl apply -f - + +kubectl apply -f examples/aks-kata/batchsandbox-template-configmap.yaml + +helm upgrade --install opensandbox-controller ./kubernetes/charts/opensandbox-controller \ + --namespace opensandbox-system \ + -f examples/aks-kata/controller-values.yaml + +helm upgrade --install opensandbox-server ./kubernetes/charts/opensandbox-server \ + --namespace opensandbox-system \ + -f examples/aks-kata/server-values.yaml +``` + +Wait for the deployments: + +```bash +kubectl rollout status deploy/opensandbox-controller-manager -n opensandbox-system --timeout=180s +kubectl rollout status deploy/opensandbox-server -n opensandbox-system --timeout=180s +kubectl rollout status deploy/opensandbox-ingress-gateway -n opensandbox-system --timeout=180s +``` + +## 2. Start port-forwards + +Open two separate terminals: + +```bash +# Terminal 1 — lifecycle server +kubectl port-forward -n opensandbox-system svc/opensandbox-server 18080:80 + +# Terminal 2 — ingress gateway +kubectl port-forward -n opensandbox-system svc/opensandbox-ingress-gateway 28080:80 +``` + +## 3. Set environment variables + +```bash +export SANDBOX_DOMAIN=http://127.0.0.1:18080 +export SANDBOX_API_KEY=aks-kata-demo-key + +export AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +export AZURE_OPENAI_API_KEY=your-real-key +# Optional: +# export AZURE_OPENAI_DEPLOYMENT=gpt-4o-mini +``` + +## 4. Enable Pause/Resume (Optional) + +Pause commits the sandbox rootfs as an OCI image and pushes it to a registry. Resume recreates the sandbox from that image. Without a registry, the `pause` and `resume` steps will log an error and continue. + +The steps below use Azure Container Registry (ACR). Any OCI registry works — see the [Pause / Resume guide](/guides/pause-resume) for the full guide. + +### Step 1: Grant AcrPush to the kubelet identity + +```bash +KUBELET_ID=$(az aks show \ + -g -n \ + --query "identityProfile.kubeletidentity.clientId" -o tsv) + +ACR_ID=$(az acr show --name --query id -o tsv) + +az role assignment create --assignee "$KUBELET_ID" --role AcrPush --scope "$ACR_ID" +``` + +### Step 2: Create the push/pull secret + +```bash +ACR_PASSWORD=$(az acr credential show --name \ + --query "passwords[0].value" -o tsv) + +kubectl create secret docker-registry acr-snapshot-push-secret \ + --docker-server=.azurecr.io \ + --docker-username= \ + --docker-password="$ACR_PASSWORD" \ + --namespace=opensandbox +``` + +### Step 3: Upgrade the controller + +```bash +helm upgrade opensandbox-controller ./kubernetes/charts/opensandbox-controller \ + --namespace opensandbox-system \ + --reuse-values \ + --set controller.snapshot.registry=.azurecr.io/opensandbox-snapshots \ + --set controller.snapshot.snapshotPushSecret=acr-snapshot-push-secret \ + --set controller.snapshot.resumePullSecret=acr-snapshot-push-secret + +kubectl rollout status deploy/opensandbox-controller-manager \ + -n opensandbox-system --timeout=90s +``` + +::: tip +This example clears `controller.snapshot.containerdSocketPath` because the pinned controller image (`controller:v0.2.0`) does not accept the `--containerd-socket-path` flag. Current controller builds **do** accept it (see [`kubernetes/cmd/controller/main.go`](https://github.com/opensandbox-group/OpenSandbox/blob/main/kubernetes/cmd/controller/main.go)); if your nodes use a non-default containerd socket and you deploy a controller image that supports the flag, set this value accordingly. +::: + +## 5. Use `main.py` + +All `main.py` commands below are run from the example directory: + +```bash +cd examples/aks-kata +``` + +### Run everything end to end + +```bash +python3 main.py all +``` + +This creates a sandbox, runs every demo step, and deletes it at the end. + +### Run one step at a time + +#### Create a sandbox + +```bash +python3 main.py create +``` + +Expected output: + +``` +Creating Kata-isolated sandbox on AKS... + OpenSandbox API: http://127.0.0.1:18080 + Sandbox image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0 + +SANDBOX_ID=96045ee2-6614-435c-9fa8-d4b6f9592598 + +Use --sandbox-id 96045ee2-6614-435c-9fa8-d4b6f9592598 for subsequent steps. +``` + +Save the ID for the following steps: + +```bash +export SANDBOX_ID=96045ee2-6614-435c-9fa8-d4b6f9592598 +``` + +Verify the pod is running on a Kata node: + +```bash +kubectl get pod -n opensandbox -o wide +# NODE column should show aks-sandboxagent-* + +kubectl get pod -n opensandbox -o jsonpath='{.items[0].spec.runtimeClassName}' +# Expected: kata-vm-isolation + +python3 main.py exec --sandbox-id $SANDBOX_ID -c "uname -r" +# Expected: 6.6.137.mshv1-1.azl3 (mshv = Microsoft Hypervisor = Kata VM guest kernel) +``` + +#### Set up Credential Vault + +```bash +python3 main.py credentials --sandbox-id $SANDBOX_ID +``` + +Expected output: + +``` +[credentials] Credential Vault configured. +``` + +#### Ask the LLM a question + +```bash +python3 main.py llm --sandbox-id $SANDBOX_ID -q "What is the capital of France? Reply in one word." +``` + +Expected output: + +``` +[llm] Question: What is the capital of France? Reply in one word. +[llm] Model: gpt-4o-mini +[llm] Answer: Paris +``` + +The sandbox only has a fake API key — the real key is injected by the egress sidecar via Credential Vault. You can verify: + +```bash +python3 main.py exec --sandbox-id $SANDBOX_ID -c "echo \$AZURE_OPENAI_API_KEY" +# Expected: fake-key-inside-sandbox +``` + +#### Run commands inside the sandbox + +```bash +python3 main.py exec --sandbox-id $SANDBOX_ID -c "uname -a" +# [exec][stdout] Linux ...-0 6.6.137.mshv1-1.azl3 ... x86_64 GNU/Linux + +python3 main.py exec --sandbox-id $SANDBOX_ID -c "cat /etc/os-release | head -3" +# [exec][stdout] PRETTY_NAME="Ubuntu 24.04.4 LTS" +# [exec][stdout] NAME="Ubuntu" +# [exec][stdout] VERSION_ID="24.04" + +python3 main.py exec --sandbox-id $SANDBOX_ID -c "ls -la /tmp/www/" +# Shows the HTTP server root directory +``` + +#### Fetch from the sandbox HTTP server + +The sandbox runs `python3 -m http.server 8080` serving `/tmp/www/`. Traffic goes through the ingress gateway with secure-access headers. + +```bash +# Write a file into the sandbox, then fetch it via the gateway +python3 main.py exec --sandbox-id $SANDBOX_ID -c "echo hello > /tmp/www/greeting.txt" +python3 main.py http --sandbox-id $SANDBOX_ID -p /greeting.txt +# [http] GET /greeting.txt -> 200 (6 bytes) +# hello + +# Directory listing +python3 main.py http --sandbox-id $SANDBOX_ID -p / +# Shows HTML directory listing + +# 404 for missing file +python3 main.py http --sandbox-id $SANDBOX_ID -p /nonexistent.txt +# [http] GET /nonexistent.txt -> 404 +``` + +#### Check sandbox status + +```bash +python3 main.py status --sandbox-id $SANDBOX_ID +# Sandbox: 96045ee2-6614-435c-9fa8-d4b6f9592598 +# State: Running +# Image: ...code-interpreter:v1.1.0 +``` + +#### Pause and resume + +Pause snapshots the container rootfs to the registry (1–5 min). Resume recreates the sandbox from that snapshot. Requires step 4 above. + +```bash +python3 main.py pause --sandbox-id $SANDBOX_ID +# [lifecycle] pausing sandbox... +# [lifecycle] sandbox is PAUSED + +python3 main.py status --sandbox-id $SANDBOX_ID +# State: Paused + +# Pod is gone, but the BatchSandbox CR and snapshot remain +kubectl get pods -n opensandbox +# No resources found +kubectl get sandboxsnapshot -n opensandbox +# Shows snapshot with phase Succeed + +python3 main.py resume --sandbox-id $SANDBOX_ID +# [lifecycle] resuming sandbox... +# [setup] sandbox state: Resuming, waiting... +# [lifecycle] state after resume: Running +# [http] GET /index.html -> 200 (filesystem preserved!) + +python3 main.py status --sandbox-id $SANDBOX_ID +# State: Running +``` + +#### Delete the sandbox + +```bash +python3 main.py delete --sandbox-id $SANDBOX_ID +# [lifecycle] sandbox 96045ee2-... deleted. + +kubectl get pods -n opensandbox +# No resources found +``` + +Without `-c`, `-q`, or `-p`, the `exec`, `llm`, and `http` steps run their built-in demo sequences. + +## What gets installed + +| Component | Namespace | Purpose | +|-----------|-----------|---------| +| `opensandbox-controller` | `opensandbox-system` | Kubernetes operator — manages BatchSandbox CRDs, pools, and snapshots | +| `opensandbox-server` | `opensandbox-system` | Lifecycle API — creates/deletes sandboxes, proxies SDK calls | +| `opensandbox-ingress-gateway` | `opensandbox-system` | Routes external HTTP traffic into sandbox pods with auth | +| BatchSandbox template | `opensandbox-system` | Pins sandbox pods onto Kata-capable AKS nodes via `nodeSelector` | + +The server is configured with: + +- `workload_provider = "batchsandbox"` +- `secure_runtime.type = "kata"` / `k8s_runtime_class = "kata-vm-isolation"` +- egress sidecar (`dns+nft` mode) +- ingress gateway (`header` routing with `secureAccess`) + +## Security Model + +::: warning +`server-values.yaml` ships with **demo-only** credentials: `api_key = +"aks-kata-demo-key"` and a fixed base64 `secureAccess` signing key. They exist so +the local port-forward walkthrough works without extra setup. Replace both with +unique secret values (for example a random key and +`openssl rand -base64 32` for the signing key) before exposing the server or +gateway to anything other than `127.0.0.1`. +::: + +### Kata VM isolation + +Each sandbox runs inside a dedicated Kata VM (`runtimeClassName: kata-vm-isolation`) on an AKS Kata node pool (`nodeSelector: kubernetes.azure.com/kata-vm-isolation: "true"`). The sandbox sees its own guest kernel, not the host kernel. + +### Egress isolation + +The create request sets a deny-by-default outbound policy. Only explicitly allowed hosts (Azure OpenAI, pypi.org, files.pythonhosted.org) can be reached. + +### Credential Vault + +The sandbox only sees a fake `AZURE_OPENAI_API_KEY`. The real key is stored in Credential Vault and injected by the egress sidecar as the `api-key` header only for matching outbound requests to `https://.openai.azure.com/openai/*`. + +### Ingress gateway + secureAccess + +All external traffic to sandbox services flows through the ingress gateway. The gateway validates the `OpenSandbox-Secure-Access` token and routes via the `OpenSandbox-Ingress-To` header. Both are returned by the SDK's `get_endpoint()` call — callers never need to construct them manually. + +### Per-service auth + +Even if someone bypasses the gateway and reaches the pod directly: +- **execd** (port 44772) requires its own access token header +- **egress sidecar** (port 18080) requires the `OPENSANDBOX-EGRESS-AUTH` header +- Only the user's HTTP server (port 8080) has no built-in auth — that's what secureAccess protects + +## Cleanup + +Remove everything installed by this example: + +```bash +# 1) Delete any running sandboxes +kubectl delete batchsandbox --all -n opensandbox + +# 2) Uninstall Helm releases +helm uninstall opensandbox-server -n opensandbox-system +helm uninstall opensandbox-controller -n opensandbox-system + +# 3) Remove the BatchSandbox template +kubectl delete configmap aks-kata-batchsandbox-template -n opensandbox-system + +# 4) Remove the snapshot push/pull secret (if created) +kubectl delete secret acr-snapshot-push-secret -n opensandbox --ignore-not-found + +# 5) Delete the namespaces +kubectl delete namespace opensandbox +kubectl delete namespace opensandbox-system +``` diff --git a/docs/examples/chrome.md b/docs/examples/chrome.md new file mode 100644 index 0000000..a2eaeeb --- /dev/null +++ b/docs/examples/chrome.md @@ -0,0 +1,80 @@ +--- +title: Chrome +description: Run Chrome Browser with OpenSandbox runtime for remote debugging via DevTools. +--- + +# Chrome Browser in OpenSandbox + +This example runs Chrome Browser with OpenSandbox runtime. + +The image starts a VNC server (`Xtigervnc :1`) and launches Chromium with remote debugging enabled on port `9222`. + +## Getting Chrome image + +You can build the image from source or pull it from Docker Hub. + +### Build from source + +```shell +cd examples/chrome +docker build -t opensandbox/chrome . +``` + +### Pull an existing image + +```shell +docker pull opensandbox/chrome:latest + +# use acr from china +# docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/chrome:latest +``` + +## Start OpenSandbox server + +Start the OpenSandbox server and tail stdout from the terminal: + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +opensandbox-server +``` + +## Create and access a Chrome sandbox + +Build/pull the image above, then create a sandbox with image `opensandbox/chrome:latest` and an entrypoint that keeps it +alive (e.g., `["/bin/sh", "-c", "sleep infinity"]`), or reuse `tail -f /dev/null`. Make sure the runtime exposes ports +`5901` and `9222` for VNC/DevTools. + +```shell +uv pip install opensandbox +uv run python examples/chrome/main.py +``` + +Then fetch endpoints for 5901/9222 to connect with a VNC client or DevTools, like: + +```text +execd daemon running with endpoint='127.0.0.1:48379/proxy/44772' +VNC running with endpoint='127.0.0.1:48379/proxy/5901' +DevTools running with endpoint='127.0.0.1:48379/proxy/9222'/json +``` + +```text +[ { + "description": "", + "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@.../inspector.html?ws=127.0.0.1:52302/devtools/page/...", + "faviconUrl": "https://www.gstatic.com/images/branding/searchlogo/ico/favicon.ico", + "id": "2215AF60AC345E4BA6D822389CFC743B", + "title": "Google", + "type": "page", + "url": "https://www.google.com.hk/", + "webSocketDebuggerUrl": "ws://127.0.0.1:52302/devtools/page/2215AF60AC345E4BA6D822389CFC743B" +} ] +``` + +Or you can use it by MCP client, more information please refer +to: [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp). + +## References + +- [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/chrome) diff --git a/docs/examples/claude-code.md b/docs/examples/claude-code.md new file mode 100644 index 0000000..9d47bcc --- /dev/null +++ b/docs/examples/claude-code.md @@ -0,0 +1,57 @@ +--- +title: Claude Code +description: Access Claude via the claude-cli npm package in an OpenSandbox container. +--- + +# Claude Code Example + +Access Claude via the `claude-cli` npm package in OpenSandbox. + +## Start OpenSandbox server [local] + +Pre-pull the code-interpreter image (includes Node.js): + +```shell +docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0 + +# use docker hub +# docker pull opensandbox/code-interpreter:v1.1.0 +``` + +Then start the local OpenSandbox server, stdout logs will be visible in the terminal: + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +opensandbox-server +``` + +## Create and Access the Claude Sandbox + +```shell +# Install OpenSandbox package +uv pip install opensandbox + +# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / ANTHROPIC_AUTH_TOKEN) +uv run python examples/claude-code/main.py +``` + +The script installs the Claude CLI (`npm i -g @anthropic-ai/claude-code@latest`) at runtime (Node.js is already in the code-interpreter image), then sends a simple request `claude "Compute 1+1=?."`. Auth is passed via `ANTHROPIC_AUTH_TOKEN`, and you can override endpoint/model with `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL`. + +![Claude Code screenshot](../public/images/claude-code-screenshot.jpg) + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SANDBOX_DOMAIN` | `localhost:8080` | Sandbox service address | +| `SANDBOX_API_KEY` | _(optional for local)_ | API key if your server requires authentication | +| `SANDBOX_IMAGE` | `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0` | Sandbox image to use | +| `ANTHROPIC_AUTH_TOKEN` | _(required)_ | Your Anthropic auth token | +| `ANTHROPIC_BASE_URL` | _(optional)_ | Anthropic API endpoint (e.g., self-hosted proxy) | +| `ANTHROPIC_MODEL` | `claude_sonnet4` | Model name | + +## References + +- [claude-code](https://www.npmjs.com/package/claude-code) - NPM package for Claude Code CLI +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/claude-code) diff --git a/docs/examples/code-interpreter.md b/docs/examples/code-interpreter.md new file mode 100644 index 0000000..84be66f --- /dev/null +++ b/docs/examples/code-interpreter.md @@ -0,0 +1,205 @@ +--- +title: Code Interpreter +description: Complete demonstration of running Python code using the Code Interpreter SDK with OpenSandbox. +--- + +# Code Interpreter Sandbox + +Complete demonstration of running Python code using the Code Interpreter SDK. + +## Getting Code Interpreter image + +Pull the prebuilt image from a registry: + +```shell +docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0 + +# use docker hub +# docker pull opensandbox/code-interpreter:v1.1.0 +``` + +## Start OpenSandbox server [local] + +Start the local OpenSandbox server: + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +opensandbox-server +``` + +## Create and access the Code Interpreter Sandbox + +```shell +# Install OpenSandbox packages +uv pip install opensandbox opensandbox-code-interpreter + +# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY) +uv run python examples/code-interpreter/main.py +``` + +The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance. + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SANDBOX_DOMAIN` | `localhost:8080` | Sandbox service address | +| `SANDBOX_API_KEY` | _(optional)_ | API key if your server requires authentication | +| `SANDBOX_IMAGE` | `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0` | Sandbox image to use | + +## Example output + +```text +=== Python example === +[Python stdout] Hello from Python! + +[Python result] {'py': '3.14.2', 'sum': 4} + +=== Java example === +[Java stdout] Hello from Java! + +[Java stdout] 2 + 3 = 5 + +[Java result] 5 + +=== Go example === +[Go stdout] Hello from Go! +3 + 4 = 7 + + +=== TypeScript example === +[TypeScript stdout] Hello from TypeScript! + +[TypeScript stdout] sum = 6 +``` + +## Code Interpreter Sandbox from pool + +### Start OpenSandbox server [k8s] + +Install the k8s OpenSandbox operator, and create a pool: + +```yaml +apiVersion: sandbox.opensandbox.io/v1alpha1 +kind: Pool +metadata: + labels: + app.kubernetes.io/name: sandbox-k8s + app.kubernetes.io/managed-by: kustomize + name: pool-sample + namespace: opensandbox +spec: + template: + metadata: + labels: + app: example + spec: + volumes: + - name: sandbox-storage + emptyDir: { } + - name: opensandbox-bin + emptyDir: { } + initContainers: + - name: task-executor-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:v0.1.0 + command: [ "/bin/sh", "-c" ] + args: + - | + cp /workspace/server /opt/opensandbox/task-executor && + chmod +x /opt/opensandbox/task-executor + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox + - name: execd-installer + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.21 + command: [ "/bin/sh", "-c" ] + args: + - | + cp ./execd /opt/opensandbox/execd && + cp ./bootstrap.sh /opt/opensandbox/bootstrap.sh && + chmod +x /opt/opensandbox/execd && + chmod +x /opt/opensandbox/bootstrap.sh + volumeMounts: + - name: opensandbox-bin + mountPath: /opt/opensandbox + containers: + - name: sandbox + image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0 + command: + - "/bin/sh" + - "-c" + - | + /opt/opensandbox/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1 + env: + - name: SANDBOX_MAIN_CONTAINER + value: main + - name: EXECD_ENVS + value: /opt/opensandbox/.env + - name: EXECD + value: /opt/opensandbox/execd + volumeMounts: + - name: sandbox-storage + mountPath: /var/lib/sandbox + - name: opensandbox-bin + mountPath: /opt/opensandbox + tolerations: + - operator: "Exists" + capacitySpec: + bufferMax: 3 + bufferMin: 1 + poolMax: 5 + poolMin: 0 +``` + +Start the k8s OpenSandbox server: + +```shell +uv pip install opensandbox-server + +# replace with your k8s cluster config, kubeconfig etc. +opensandbox-server init-config ~/.sandbox.toml --example k8s +curl -o ~/batchsandbox-template.yaml https://raw.githubusercontent.com/opensandbox-group/OpenSandbox/main/server/opensandbox_server/examples/example.batchsandbox-template.yaml + +opensandbox-server +``` + +### Create and access the Code Interpreter Sandbox (pool) + +```shell +# Install OpenSandbox packages +uv pip install opensandbox opensandbox-code-interpreter + +# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY) +uv run python examples/code-interpreter/main_use_pool.py +``` + +### Pool example output + +```text +=== Verify Environment Variable === +[ENV Check] TEST_ENV value: test + +[ENV Result] 'test' + +=== Java example === +[Java stdout] Hello from Java! + +[Java stdout] 2 + 3 = 5 + +[Java result] 5 + +=== Go example === +[Go stdout] Hello from Go! +3 + 4 = 7 + + +=== TypeScript example === +[TypeScript stdout] Hello from TypeScript! + +[TypeScript stdout] sum = 6 +``` + +## References + +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/code-interpreter) diff --git a/docs/examples/codex-cli.md b/docs/examples/codex-cli.md new file mode 100644 index 0000000..8a0a970 --- /dev/null +++ b/docs/examples/codex-cli.md @@ -0,0 +1,55 @@ +--- +title: Codex CLI +description: Use the official @openai/codex npm package to call OpenAI/Codex-like models in OpenSandbox. +--- + +# Codex/OpenAI CLI Example + +Use the official `@openai/codex` npm package to call OpenAI/Codex-like models in OpenSandbox. + +## Start OpenSandbox server [local] + +Pre-pull the code-interpreter image (includes Node.js): + +```shell +docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0 + +# use docker hub +# docker pull opensandbox/code-interpreter:v1.1.0 +``` + +Start the local OpenSandbox server, logs will be visible in the terminal: + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +opensandbox-server +``` + +## Create and Access the Codex Sandbox + +```shell +# Install OpenSandbox package +uv pip install opensandbox + +# Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / OPENAI_API_KEY) +uv run python examples/codex-cli/main.py +``` + +The script installs the Codex CLI (`npm install -g @openai/codex@latest`) at runtime (Node.js is already in the code-interpreter image), then executes a simple request `codex exec "Compute 1+1 and return JSON with keys result and reasoning." --skip-git-repo-check`. Auth is passed via `OPENAI_API_KEY`; you can override endpoint/model with `OPENAI_BASE_URL` / `OPENAI_MODEL`. + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SANDBOX_DOMAIN` | `localhost:8080` | Sandbox service address | +| `SANDBOX_API_KEY` | _(optional for local)_ | API key if your server requires authentication | +| `SANDBOX_IMAGE` | `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.1.0` | Sandbox image to use | +| `OPENAI_API_KEY` | _(required)_ | Your OpenAI API key | +| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API endpoint | +| `OPENAI_MODEL` | `gpt-4o-mini` | Model to use | + +## References + +- [@openai/codex](https://www.npmjs.com/package/@openai/codex) - Official OpenAI Codex CLI +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/codex-cli) diff --git a/docs/examples/desktop.md b/docs/examples/desktop.md new file mode 100644 index 0000000..ca5d0b8 --- /dev/null +++ b/docs/examples/desktop.md @@ -0,0 +1,74 @@ +--- +title: Desktop (VNC) +description: Launch Xvfb + x11vnc + fluxbox in OpenSandbox to provide a VNC-accessible desktop environment. +--- + +# Desktop (VNC) Example + +Launch Xvfb + x11vnc + fluxbox in OpenSandbox to provide a VNC-accessible desktop environment. + +## Build the Desktop Sandbox Image + +The Dockerfile in the example directory builds a sandbox image with desktop and VNC components pre-installed: + +```shell +cd examples/desktop +docker build -t opensandbox/desktop:latest . +``` + +This image includes: +- Xvfb (virtual framebuffer X server) +- x11vnc (VNC server) +- XFCE desktop (panel, file manager, terminal) +- Non-root user (desktop) for security + +## Start OpenSandbox server [local] + +Pre-pull the desktop image: + +```shell +docker pull opensandbox/desktop:latest +``` + +Start the local OpenSandbox server: + +```shell +uv pip install opensandbox-server +opensandbox-server init-config ~/.sandbox.toml --example docker +opensandbox-server +``` + +## Create and Access the Desktop Sandbox + +```shell +# Install OpenSandbox package +uv pip install opensandbox + +uv run python examples/desktop/main.py +``` + +The script starts the desktop stack (Xvfb + XFCE + x11vnc) and also launches noVNC/websockify. It prints: +- VNC endpoint (`endpoint.endpoint`) for native VNC clients, password from `VNC_PASSWORD` (default: `opensandbox`) +- noVNC URL for browsers (`/vnc.html?host=...&port=...&path=...`) + +The sandbox stays alive for 5 minutes by default; interrupt sooner with Ctrl+C. Uses the prebuilt desktop image by default. + +![Desktop shell](../public/images/desktop-screenshot-shell.jpg) +![noVNC connect](../public/images/desktop-screenshot-connect.jpg) +![noVNC password](../public/images/desktop-screenshot-password.jpg) +![Desktop UI](../public/images/desktop-screenshot-desktop.jpg) + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `SANDBOX_DOMAIN` | `localhost:8080` | Sandbox service address | +| `SANDBOX_API_KEY` | _(optional for local)_ | API key if your server requires authentication | +| `SANDBOX_IMAGE` | `opensandbox/desktop:latest` | Sandbox image to use | +| `VNC_PASSWORD` | `opensandbox` | Password for VNC access | + +## References + +- [noVNC](https://github.com/novnc/noVNC) +- [x11vnc](https://github.com/LibVNC/x11vnc) +- [Source code on GitHub](https://github.com/opensandbox-group/OpenSandbox/tree/main/examples/desktop) diff --git a/docs/examples/docker-ossfs-volume-mount.md b/docs/examples/docker-ossfs-volume-mount.md new file mode 100644 index 0000000..2bf0d82 --- /dev/null +++ b/docs/examples/docker-ossfs-volume-mount.md @@ -0,0 +1,120 @@ +--- +title: Docker OSSFS Volume +description: Mount Alibaba Cloud OSS into sandboxes on Docker runtime using the OSSFS volume model. +--- + +# Docker OSSFS Volume Mount Example + +This example demonstrates how to use the SDK `ossfs` volume model to mount Alibaba Cloud OSS into sandboxes on Docker runtime. + +## What this example covers + +1. **Basic read-write mount** on an OSSFS backend. +2. **Cross-sandbox sharing** on the same OSSFS backend path. +3. **Two mounts, different OSS prefixes via `subPath`**. + +## Prerequisites + +### 1. Start OpenSandbox server (Docker runtime) + +Make sure your server host has: + +- Linux host OS (OSSFS backend is not supported when OpenSandbox Server runs on Windows) +- `ossfs` installed +- FUSE support enabled +- writable local mount root for OSSFS (default `storage.ossfs_mount_root=/mnt/ossfs`) + +`storage.ossfs_mount_root` is **optional** if you use the default `/mnt/ossfs`. +Even with on-demand mounting, the runtime still needs a deterministic host-side +base directory to place dynamic mounts (`//`). + +Optional config example: + +```toml +[runtime] +type = "docker" + +[storage] +ossfs_mount_root = "/mnt/ossfs" +``` + +Then start the server: + +```bash +opensandbox-server +``` + +### 2. Install Python SDK + +```bash +uv pip install opensandbox +``` + +If your PyPI version does not include OSSFS volume models yet, install from source: + +```bash +pip install -e sdks/sandbox/python +``` + +### 3. Prepare OSS credentials and target path + +```bash +export SANDBOX_DOMAIN=localhost:8080 +export SANDBOX_API_KEY=your-api-key +export SANDBOX_IMAGE=ubuntu + +export OSS_BUCKET=your-bucket +export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +export OSS_ACCESS_KEY_ID=your-ak +export OSS_ACCESS_KEY_SECRET=your-sk +``` + +## Run + +```bash +uv run python examples/docker-ossfs-volume-mount/main.py +``` + +## Minimal SDK usage snippet + +```python +from opensandbox import Sandbox +from opensandbox.models.sandboxes import OSSFS, Volume + +sandbox = await Sandbox.create( + image="ubuntu", + volumes=[ + Volume( + name="oss-data", + ossfs=OSSFS( + bucket="your-bucket", + endpoint="oss-cn-hangzhou.aliyuncs.com", + # version="2.0", # optional, default is "2.0" + accessKeyId="your-ak", + accessKeySecret="your-sk", + ), + mountPath="/mnt/data", + subPath="train", # optional + readOnly=False, # optional + ) + ], +) +``` + +## Notes + +::: info Implementation details +- Current implementation supports **inline credentials only** (`accessKeyId`/`accessKeySecret`). +- Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets. +- `ossfs.version` exists in API/SDK with enum `"1.0" | "2.0"`, and defaults to `"2.0"` when omitted. +- Docker runtime now applies **version-specific mount argument encoding**: + - `1.0`: mounts via `ossfs ... -o